テストでlocalstack利用時に起動時のPort待受が完了するまで待機するようにしてみた

LocalStackを使ったテスト時に確実にPortが受け付けるまで待機するようにしつつ、テストコード上ではlocalstackの存在を意識する必要がなくなるようにしてみました。
2021.03.23

AWSへのアクセスが必要なコードのテスト用途にlocalstackを利用していると、幾つかの課題が出てきます。

  • localstackを導入していない環境ではエラーになる
  • localstackの起動が完了する前にテストを実行してエラーになる
  • Actions等のCI環境で起動が完了する前にテストが走ってエラーになる

他にも懸念事項はあるかもしれませんが、今回は上記3点についてとった対策について書いてみます。

テストコード内でlocalstackの起動判定を入れる

localstackが起動していない場合にエラー扱いとしないために、HTTPリクエストを受付なければskipさせる方法でやってみます。

import requests

try:
    res = requests.get('http://localhost:4566', timeout=5)
    return res.json()['status'] == 'running'
except Exception:
    return False

こんな感じで問題ありません。ただ、localstackを使うテスト毎にこれを書き入れると変更コストが高くなります。そこで専用のクラスにて対処します。

from unittest import TestCase
import unittest
import requests
import os
import boto3


HOSTNAME = os.environ['LOCALSTACK_HOSTNAME']
PORT = os.environ['EDGE_PORT']


def is_avaliable_localstack():
    try:
        res = requests.get('http://localhost:4566', timeout=5)
        return res.json()['status'] == 'running'
    except Exception:
        return False


@unittest.skipIf(not is_avaliable_localstack(), 'localstack not running')
class SettingsLocalStack(TestCase):

    @classmethod
    def get_client(self, service):
        return boto3.client(service,
                            endpoint_url=f'http://{HOSTNAME}:{PORT}/',
                            region_name='us-east-1')

    @classmethod
    def get_resource(self, service):
        return boto3.resource(service,
                              endpoint_url=f'http://{HOSTNAME}:{PORT}/',
                              region_name='us-east-1')

継承して使うと、localstackを意識せずともlocalstackの状況に合わせて自動でSkipされます。

ローカル環境でlocalstackの起動完了を待つ

起動用に以下の内容でファイルを作成しておきます。

version: '2.1'
services:
  localstack:
    image: localstack/localstack
    ports:
      - "4566:4566"
      - "4571:4571"
      - "${PORT_WEB_UI-8080}:${PORT_WEB_UI-8080}"
    environment:
      - SERVICES=${SERVICES- }
      - DEBUG=${DEBUG- }
      - DATA_DIR=${DATA_DIR- }
      - PORT_WEB_UI=${PORT_WEB_UI- }
      - LAMBDA_EXECUTOR=${LAMBDA_EXECUTOR- }
      - KINESIS_ERROR_PROBABILITY=${KINESIS_ERROR_PROBABILITY- }
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "${TMPDIR:-/tmp/localstack}:/tmp/localstack"
volumes:
  localstack:

localstackの起動時には以下のログを見ることができます。

% docker-compose up -d
Creating tests_localstack_1 ... 
Creating tests_localstack_1 ... done

ポイントは、このログ表示時にHTTPリクエストを送っても確実に受け付けてくれるかは分かりません。Docker Desktopでログを見ると、Port待受処理が順次実行されている最中だとわかります。

そこで起動待ちとして、以下のようなバッチで実行してみます。

#!/bin/sh
docker-compose down
docker-compose up -d
while [ "$(curl -s http://localhost:4566 | jq -r '.status')" != "running" ]
do
  echo "booting.."
  sleep 2
done
echo "waiting http://localhost:4566"
Stopping tests_localstack_1 ... done
Removing tests_localstack_1 ... done
Removing network tests_default
Creating network "tests_default" with the default driver
Creating tests_localstack_1 ... done
booting..
booting..
booting..
booting..
waiting http://localhost:4566

これでバッチ実行終了時点でリクエストを受け付けてくれる状態とわかるようになりました。

なお、curlに-sオプションを付けなかった場合、

Stopping tests_localstack_1 ... done
Removing tests_localstack_1 ... done
Removing network tests_default
Creating network "tests_default" with the default driver
Creating tests_localstack_1 ... done
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
curl: (52) Empty reply from server
waiting
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
curl: (52) Empty reply from server
waiting
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
curl: (52) Empty reply from server
waiting
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
curl: (52) Empty reply from server
waiting
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
curl: (52) Empty reply from server
waiting
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    21  100    21    0     0    118      0 --:--:-- --:--:-- --:--:--   118

のように進捗が大量に表示されます。省略したい場合は忘れずつけましょう。

CIでlocalstackがリクエストを受け付けるまで待ちを入れる

以下のようなStepをActionsで実行したとします。

    - name: Test
      run: |
        docker-compose -f tests/docker-compose.yml up -d
        pipenv run test
      env:
          AWS_ACCOUNT_ID: "000000000000"
          AWS_ACCESS_KEY_ID: dummy-access-key
          AWS_SECRET_ACCESS_KEY: dummy-secret-key

ポイントはHTTPリクエストの受付を待たずにテストが実行されてしまい、SKIPとなる点です。

Creating network "tests_default" with the default driver
Creating volume "tests_localstack" with default driver
Pulling localstack (localstack/localstack:)...
latest: Pulling from localstack/localstack
Digest: sha256:81a7b7f12223fcd6c4f596baaf004c19e7a1f815887116c7f7f25962b7a7e89e
Status: Downloaded newer image for localstack/localstack:latest
Creating tests_localstack_1 ... 
Creating tests_localstack_1 ... done
============================= test session starts ==============================
..
tests/test_example.py::CheckFinalized::test_check_finalized_0_1 SKIPPED [ 18%]
tests/test_example.py::CheckFinalized::test_check_finalized_1_1 SKIPPED [ 19%]
..

ローカル環境での待機と同じものを入れてみます。

    - name: Test
      run: |
        docker-compose -f tests/docker-compose.yml up -d
        while [ "$(curl -s http://localhost:4566 | jq -r '.status')" != "running" ]
        do
          echo "waiting"
          sleep 2
        done
        pipenv run test
      env:
          AWS_ACCOUNT_ID: "000000000000"
          AWS_ACCESS_KEY_ID: dummy-access-key
          AWS_SECRET_ACCESS_KEY: dummy-secret-key
Creating network "tests_default" with the default driver
Creating volume "tests_localstack" with default driver
Pulling localstack (localstack/localstack:)...
latest: Pulling from localstack/localstack
Digest: sha256:bdfbe53666a4dd13a09dd9e4b155e2fb750b8041daf7efc69783cb4208b6cacc
Status: Downloaded newer image for localstack/localstack:latest
Creating tests_localstack_1 ... 
Creating tests_localstack_1 ... done
waiting
waiting
waiting
waiting
============================= test session starts ==============================
..
tests/test_example.py::CheckFinalized::test_check_finalized_0_1 PASSED [ 18%]
tests/test_example.py::CheckFinalized::test_check_finalized_1_1 PASSED [ 19%]
..

正常にテストされるようになりました。

あとがき

localstack/services/infra.pyの存在も認識はしていましたが、依存の解決が上手く行かなかったため今回の方法をとりました。

localstackを利用したテスト設計は最初こそ手間が掛かるものの、一度上手くいったら後はテストを追加するだけとなります。

AWSを利用したコードのテストには欠かせないライブラリです。慣れるまで少し掛かるかもしれませんが、根気よく付き合ってみることをおすすめします。