ls /asapon/blog

基本tech、時々多趣味

RSpecで書く基底クラスのテスト実践例

これはなにか

RSpecを書くときに役立つ、実践例を書いた記事です。今回は

  • 基底クラス

のテストを、改善前と改善後合わせて紹介します。他テスト(Minitestや他プログラミング言語)でも応用できる考え方なので、参考にしてみてください。

基底クラスをテストする

クラス例は以下の base.rb になります。
それぞれのメソッドは

  • #greet
    • 継承先で実装される想定
    • ここでは NotImplementedError をraiseするだけ
  • #default_language
    • 初期値は :ja
    • 継承先の必要に応じてオーバーライドされる

といった単純なものになっています。
このクラスをどのようにテストするか考えていきます。

# base.rb

class Base
  def initialize; end

  def greet
    raise NotImplementedError
  end

  def default_language
    :ja
  end
end

改善前

普通に実装すると以下のようになると思います。
やっていることは Base クラスをインスタンス化し、それぞれのメソッドをテストしているだけです。

# base_spec.rb

require_relative '../base'
require 'spec_helper'

RSpec.describe Base do
  let(:baes) { described_class.new }

  describe '#greet' do
    it 'raises NotImplementedError' do
      expect { baes.greet }.to raise_error(NotImplementedError)
    end
  end

  describe '#default_language' do
    it 'returns :ja' do
      expect(baes.default_language).to eq :ja
    end
  end
end

このテストの改善点

このテストには気になる点があります。それは

Baseクラスそのものをインスタンス化している

いう点です。
本来Baseクラスは、継承先で使われることを想定して作られているはずです。そのため、Baseクラスそのものをインスタンス化するといった使われ方は想定しておらず、実態とは異なる使い方でテストしていることになります。
基底クラスをテストするときは、安直にインスタンス化せず、継承させたあとにテストするべきです。

改善後

下が改善した基底クラスのテストになります。ポイントは

  • Class.#new で派生クラスを作っている
  • 派生クラスをインスタンス化し、メソッドを呼んでいる

という点です。
派生クラスはClassを利用しています。これで簡単に、ガワになる継承先を作ることができました。
また、オーバーライド後の動作も確認できるように

  • when #default_language is overrided to :en

といったテストも作ることができました。より実践に沿った、テストケースになったと思います。

# base_spec.rb

require_relative '../base'
require 'spec_helper'

RSpec.describe Base do
  let(:child_class) do
    Class.new(described_class)
  end
  let(:child_class_instance) { child_class.new }

  describe '#greet' do
    it 'raises NotImplementedError' do
      expect { child_class_instance.greet }.to raise_error(NotImplementedError)
    end
  end

  describe '#default_language' do
    it 'returns :ja' do
      expect(child_class_instance.default_language).to eq :ja
    end

    context 'when #default_language is overrided to :en' do
      let(:child_class) do
        Class.new(described_class) do
          def default_language
            :en
          end
        end
      end

      it 'returns :en' do
        expect(child_class_instance.default_language).to eq :en
      end
    end
  end
end

技術雑誌読んでみたら結構良かったよというお話

なんで技術雑誌を読み始めたの?

1on1で「技術雑誌を読んでみたらどうだろう」とアドバイスをもらったことがきっかけです。
知識の引き出しを増やしたいという気持ちはあったのですが、具体的にどう手を付けようか迷っていたので「何かのとっかかりになれば」という思いから読んでみました。幸い会社で「WEB+DB PRESS」と「Software Design」を定期購読していたので、気軽に手に取ることができました。

雑誌読んでみてどうだったの?

KPTで振り返ってみます。

K

  • 技術書と違って、色々な知識を雑多に集めることができる
  • 興味の湧く内容だけを集めることができる
  • 技術書よりは薄くて読みやすい
  • 本には載らないニッチな分野がある

P

  • 一つの分野について体系化されていない、または体系化されるまで時間がかかる
  • シリーズものはバックナンバーを読み漁る必要がある
  • 興味がない分野は読む気力がでない

T

  • 興味がない内容でも、触りは読んでみる

感想

良かった点

やはり雑誌というだけあって、色々な分野について書かれていました。
技術の深堀りもあれば基本的なものもあり、流行りものをキャッチアップすることもあれば、めっちゃニッチな内容(Ruby2.7のメモリ扱いやGoのビルドキャッシュなど)も書かれていました。 またCTOへのインタビュー記事などもあり、読み物としても面白かったです。
ここで感じたことは、「技術雑誌はエンジニア万人が読める内容である」ということです。今まで「技術雑誌というものは、私のような未熟者には荷が重いのではないだろうか?」と思っていました。しかし、いざ読んでみると、むしろ未熟者だからこそ読んでほしい内容が結構抑えられているなと感じました。もし前の私のような考えを持っている人がいれば、ぜひ一度読んでほしいと思います。

気になった点

雑誌であるがゆえに、興味のない分野も掲載されています。これを読む読まないは人それぞれだと思いますが、とりあえず触りだけ読むようにするくらいで良いと思います。おそらく無意識的に避けている内容に出会えるので、「いつか使うかもしれない知識のフックになれば」という気持ちでサラッと読めば良さそうです。
またシリーズものを全て抑えようとすると、バックナンバーが読める環境が必要です。必ずしも過去に遡る必要はありませんが、シリーズ全部ちゃんと読みたいときは古本を漁る必要があるかもしれません。

まとめ

当初の目的通り、知識の引き出しを増やせている気がします。雑誌で浅くても触れることができれば、あとは専門の技術本を買うなりググるなりすれば良さそうだと感じました。引き続き雑誌は読み進めていこうと思います。

Composeの起動順番をアプリケーション含め制御する

はじめに

Selenium環境をDocker内で完結させるの続きっぽい感じになっています。ですが、本記事のテーマである「起動順番の制御」は、上の記事を読まなくても理解できるよう心掛けて書きました。
例えば「続きっぽい」と感じられる要素は、実装時に書いたアプリケーション独自のものに留めています(rakeタスクやコンテナのサービスといったもの)。また、不要と思ったコード・コマンドは省略しました。

忙しい人向け

  • 起動順番を制御するには、ラッパースクリプトを用意すると良い
  • 方法は3種類ある
  • 特に大きな差はないのでお好みで

どんな問題が起こったのか

docker-compose up で立ち上げると、Selenium::WebDriverクライアントでエラーが発生しました。原因はSelenium::WebDriverクライアントを作るときに、Seleniumコンテナ内のChromeが立ち上がっていないことだった。
対策として、Chromeが立ち上がったあとにクライアントが作られるよう、依存関係を制御する必要があると分かった。

depends_on では駄目なのか

こういった依存関係の制御は、 depends_onlinks などのコマンドで制御できると思っていました。しかし実際は、サービスの起動順番しか面倒をみないようです。
つまり、コンテナ内のアプリケーションが実行可能かどうかまでは面倒を見てくれないということになります。
この仕様理由は、Compose の起動順番を制御で説明されています。以下は引用。

たとえば、データベースの準備が整うまで待つのであれば、そのことが分散システム全体に対する大きな問題になり得ます。プロダクションでは、データベースは利用不可能になったり、あるいは別のホストに移動したりする場合があるでしょう。 アプリケーションは、障害発生に対して復旧する必要があるためです。 データベースに対する接続が失敗したら、アプリケーションは再接続を試みるように扱わなくてはいけません。アプリケーションは再接続を試みるため、データベースへの接続を定期的に行う必要があるでしょう。

解決策

公式では

アプリケーションのコード上で解決する

ことが望ましいと説明しています。ただ、この処理を用意するのが面倒な場合は、「実行前にアプリケーションが立ち上がるのを待ってくれるラッパー」を用いることで対処できます。
ラッパーの準備は大きく二つに分かれます。

  • ツールを使う方法
  • 独自のラッパーを使う方法

ツールを使う場合

ラッパー用のツールは主に二つあります。

dockerize

dockerizeは、コンテナ内のアプリケーションの動作に関わる便利機能を提供してくれるツールです。その中でも今回は、アプリケーションがスタートするまで待機する機能を利用します。この機能では、ホスト・ポートだけでなく、HTTP(S)といった上位プロトコルも使ってヘルスチェックすることが可能です。

実装例

実装例は以下の流れになっています。

  1. dockerize をインストールする
  2. dockerize-wait オプションで実行する
    1. <protocol>://<host>:<port> の順番で指定する
  3. ヘルスチェックが通ったあと、引数のコマンドを指定する

Dockerfile

FROM ruby:2.7.0-alpine

# Alpine用のdockerizeをインストール
RUN apk add --no-cache openssl
ARG DOCKERIZE_VERSION="v0.6.1"
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
    && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
    && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz

docker-compose.yml

services:
  ruby:
    build: .
   command: ['dockerize', '-wait', 'tcp://chrome:4444', 'bundle', 'exec', 'rake', 'reserve']
    volumes:
      - .:/var/app
    depends_on:
      - chrome
  chrome:
    image: "selenium/standalone-chrome-debug:3.141.59"
    ports:
      - "4444:4444"
      - "5900:5900"
    volumes:
      - /dev/shm:/dev/shm

wait-for-it

wait-for-itはピュアなbashスクリプトで書かれています。そのため、依存関係を極力廃した作りになっています。また機能もシンプルになっており、扱いやすいものになっているのが特徴です。こちらのプロトコルtcpで固定です。

実装例

Dokerizeと違い、wait-for-itのREADMEには、イメージへの含め方が書いてありませんでした。そのため以下にあげる実装方法は、私が考え調べたものになります。

今回は後者の方法を使います。

  1. https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh を直接イメージにADDする
  2. wait-for-it.sh を実行する
  3. ヘルスチェックが通ったあと、引数のコマンドを指定する

Dockerfile

FROM ruby:2.7.0-alpine

ARG WORK_DIR="/var/app/"
WORKDIR ${WORK_DIR}

# alpineはbashのインストールが必要
RUN apk add --update --no-cache bash

# ルートディレクトリにスクリプトを置く
# ${WORK_DIR} 以下に置くと、ローカルがマウントするときファイルが消えてしまうため
ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /wait-for-it.sh
RUN chmod +x /wait-for-it.sh

docker-compose.yml

services:
  ruby:
    build: .
   command: ['/wait-for-it.sh', 'chrome:4444', '--', 'bundle', 'exec', 'rake', 'reserve']
    volumes:
      - .:/var/app
    depends_on:
      - chrome
  chrome:
    image: "selenium/standalone-chrome-debug:3.141.59"
    ports:
      - "4444:4444"
      - "5900:5900"
    volumes:
      - /dev/shm:/dev/shm

独自のラッパーを使う場合

ラッパースクリプトは自作することもできます。シェルスクリプトで、アプリケーションのヘルスチェックをするのが主です。
ヘルスチェックは、CLIでクライアントが呼び出せる場合

until PGPASSWORD=$POSTGRES_PASSWORD psql -h "$host" -U "postgres" -c '\q'; do

のように、シェルスクリプト内で直接コマンドを実行しても良いです(実装は公式ドキュメント参照)。
今回はSelenium::WebDriverクライアントを作ることができるか調べたいので、ヘルスチェック用のrakeタスクを用意し実行しています。

実装例

実装例は以下の流れになっています。

  1. ラッパースクリプトである wait-for-chrome.sh を実行する
  2. ヘルスチェック用のrakeタスクを実行し、エラーが発生したら数秒間スリープする
  3. ヘルスチェックが通ったら、引数で受けたコマンドを exec する

docker-compose.yml

services:
  ruby:
    build: .
    command: ['./wait-for-chrome.sh', 'bundle', 'exec', 'rake', 'reserve']
    volumes:
      - .:/var/app
    depends_on:
      - chrome
  chrome:
    image: "selenium/standalone-chrome-debug:3.141.59"
    ports:
      - "4444:4444"
      - "5900:5900"
    volumes:
      - /dev/shm:/dev/shm

wait-for-chrome.sh

# シェバンはコンテナのベースイメージに合わせる
# ex. alpineなら #!/bin/ash or #!/bin/sh

#!/bin/bash

set -e

cmd="$@"

until bundle exec rake health_check; do
  >&2 echo "chrome is unavailable - sleeping"
  sleep 3
done

>&2 echo "chrome is up - executing command"
exec $cmd

おわりに

他にも色々ラッパーはありそうですが、とりあえずここのやり方さえ抑えておけば、起動順番の制御は問題なさそうです。

参考