LocalStackをGitHub Actions上で動かしてテストする

LocalStack on GitHub Actions!
2021.01.31

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

CX事業本部の佐藤智樹です。

最近はブログあまり書いてなかったので、リハビリがてら最近やっていることを記事にしていきます。

今回はAWS上のサービスをローカル環境でテストするためのフェイクサービスとして機能するLocalStackをGitHub Actions上で動かして、LocalStackを使ったテストをCI/CDに組み込む方法を紹介します。

LocalStackでのテストが増えてくるとローカル環境でのテスト実行時間も伸びてくるので、開発フローの一部に組み込むと良さそうです。対象の読者はタイトルの通りでGitHub ActionsなどのCI/CDツール上でLocalStackを動かしたい方です。

記事にしてますがやること自体はかなり簡単だったので、LocalStack使われている方は是非試してみてください。

サンプルコード

今回使用するコードは以下のリポジトリに上げてあります。深く紹介しない部分もあるので、全体が気になった場合は以下のリポジトリをご確認ください。

Node.jsを対象にコードを記述していますが、GitHub ActionsやLocalStackの設定は他の言語でもほぼ変わらないので参考になると思うのでご確認ください。

テスト対象となる実装の概要

テスト対象のコードとして以下のようにLambda->S3でデータを取得するコードを使用します。以下の画像のようにS3に当たる部分をLocalStackに置き換えるように実装します。

実装

本章からはLocalStack、GitHub Actionsの設定、Lambdaの実装、テストコードの実装、実行結果の確認を行います。

LocalStackの設定

まずLocalStackの設定内容について説明します。複数のフェイクサービス(RDBMSなど他のサービス)を動かす想定なので、docker-composeファイルで構成します。リポジトリの直下にある「fake-service.yml」にLocalStackの設定を書いています。基本的にLocalStack公式のリポジトリにある設定ファイルから今回必要な設定だけ抜き出して記入しています。

fake-service.yml

version: '3.1'

services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME-localstack_main}"
    image: localstack/localstack
    network_mode: bridge
    environment:
      - SERVICES=s3
    ports:
      - "4566:4566"
    volumes:
      - "${TMPDIR:-/tmp/localstack}:/tmp/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

environmentの部分にはカンマ区切りで必要なサービス(例えばsnsやsqs、dynamodbなど)を追記することで使用したいフェイクサービスを絞ることができます。また公式のように「- SERVICES=${SERVICES- }」と記述すれば環境変数から使用するサービスを指定することもできます。

余談ですが昔の記事ではLocalStackのポート番号は使用するAWSのフェイクサービスごとに分かれていましたが、最近は複数のサービスを同じポート番号(今回は4566番)で呼び出すことができるようになっています。

GitHub Actionsの設定

次にGitHub Actionsの設定です。リポジトリの「/.github/workflows/」配下に作成したymlファイルが自動で読み込まれます。

/.github/workflows/main.yml

name: CI

on:
  pull_request:
    branches: [ master ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '12'
          check-latest: true

      - name: Unit Test
        env:
          DEFAULT_REGION: ap-northeast-1
          AWS_ACCOUNT_ID: "000000000000"
          AWS_ACCESS_KEY_ID: dummy-access-key
          AWS_SECRET_ACCESS_KEY: dummy-secret-key
        run: |
          yarn --frozen-lockfile
          docker-compose -f fake-service.yml up -d
          yarn unit-test

環境変数としてLocalStack上で使用するダミーのAWSアクセスキーやシークレットキーなどの設定が必要です。ジョブはプルリクエストにコードを反映したタイミングで動作するように設定します。

Lambdaを実装

テスト対象となるLambdaのコードを実装します。以下の資料の呼び出し方を参考にS3の呼び出し部分をhandler外部に切り出します。

/src/lambda/handler.ts

import * as AWS from "aws-sdk";

import { S3Client } from "./s3-client"

const S3_CLIENT_PARAM = process.env.S3_CLIENT_PARAM!;
const s3 = new AWS.S3(JSON.parse(S3_CLIENT_PARAM));
const BUCKET_NAME = process.env.BUCKET_NAME!;

interface Event {
  fileName: string;
}

export const handler = async (event: Event) => {
  const s3Client = new S3Client(s3,{
    Bucket: BUCKET_NAME,
    Key: event.fileName
  });

  const result = await s3Client.getObject();
  console.log(result)
}

handler部分では、S3クライアントの実行パラメータだけ渡すように設計します。S3からイベント実行する場合は、BUCKET_NAMEの部分をイベントから取得するように記述した方が良いです。次はhandlerで呼び出しているs3Clientを紹介します。

/src/lambda/s3-client.ts

import * as AWS from "aws-sdk";

export class S3Client {
  s3Client: AWS.S3;
  s3GetObjectParam: AWS.S3.GetObjectRequest;

  constructor(s3Client: AWS.S3, s3GetObjectParam: AWS.S3.GetObjectRequest){
    this.s3Client = s3Client;
    this.s3GetObjectParam = s3GetObjectParam;
  };

  getObject = async () => {
    const s3Object = await this.s3Client.getObject(this.s3GetObjectParam).promise();
    return s3Object.Body!.toString();
  }
}

上記のコードが実際にS3からデータを取得している部分になります。上記のコードのs3Clientへフェイクサービスを向くように設定したs3Clientを渡すことでローカルではLocalStackを使ってテストするように設定できます。

テストコードの実装

最後にテストコードについて記載します。S3と接続するのは「s3-client.ts」ファイルのみなので、「s3-client.ts」だけをテストします。

/tests/unit/s3-client.test.ts

import * as fs from "fs";
import * as path from "path";
import * as AWS from "aws-sdk";
import { S3Client } from "../../src/lambda/s3-client";

const bucketName = "test";
const testFileName = "event.json";

const s3 = new AWS.S3({
  region: "ap-northeast-1",
  signatureVersion: "v4",
  s3ForcePathStyle: true,
  endpoint: new AWS.Endpoint("http://localhost:4566")
});

// takes time to execute LocalStack
jest.setTimeout(10000);

describe("getObject", () => {
  beforeAll(async () => {
    await s3.createBucket({ Bucket: bucketName }).promise();
    await s3.putObject({
       Bucket: bucketName,
       Key: testFileName,
       Body: fs.readFileSync(
         path.join(__dirname, "events", testFileName)
       ),
    }).promise();
  });
  afterAll(async () => {
    const deleteObjects: AWS.S3.Types.ListObjectsOutput = await s3.listObjects({ Bucket: bucketName }).promise();
    if(deleteObjects.Contents){
      for (const deleteObject of deleteObjects.Contents){
        await s3.deleteObject({Bucket: bucketName, Key: deleteObject.Key as string }).promise();
      }
    }
    await s3.deleteBucket({ Bucket: bucketName }).promise();
  });
  test("get s3 object", async () => {
    const s3Client = new S3Client(
      s3,
      {
        Bucket: bucketName,
        Key: testFileName
      }
    );
    const result = await s3Client.getObject();

    const expectFile = fs.readFileSync(path.join(__dirname, "events", testFileName)).toString();

    expect(result).toEqual(expectFile);
  });
});

beforeAllでLocalStack上にS3バケットとオブジェクトを作成し、testでオブジェクト取得するテストを実行後、afterAllでLocalStack上で作成したS3バケットとオブジェクトを削除しています。この後は上記のコードがローカルやGitHub Actions上で正しく動作するか確認します。

ローカルでの動作確認

GitHub Actions上でテストする前に、テストコード自体が転けている可能性があるので、ローカルで動作するのか確認します。

以下のコマンドでLocalStackをローカルで起動します。

$ docker-compose -f fake-service.yml up -d

パッケージなどの依存関係をyarnでまとめた後、「yarn unit-test」でテストを実行します。問題なければ以下のようにテストの成功を確認できます。

$ yarn
$ yarn unit-test
yarn run v1.21.1
warning package.json: No license field
$ jest unit --runInBand --config jest.config.js
 PASS  tests/unit/s3-client.test.ts (9.736 s)
  getObject
    ✓ get s3 object (29 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        12.033 s
Ran all test suites matching /unit/i.
✨  Done in 14.79s.

GitHub Actions上での動作確認

ローカルでの確認が済んだので、こちらの設定がGitHub Actions上でも動くのか確認します。実際にプルリクを作成して動作確認をしたURLを添付しておきます。画面右下の「Unit Test」から結果を確認できます。(途中でパラメータ忘れてプルリクのcommitがグダってる部分はご了承ください)

AWS環境での動作確認

念のためAWS環境でも正確に動作するか確認します。今回はコードやリソースをCDKでデプロイするように設定しているので、AWSの認証情報を設定後以下のコマンドでデプロイします。

$ cdk deploy

デプロイ完了後、Lambdaで取得するファイルをS3に格納します。

$ aws s3 cp tests/unit/events/event.json s3://local-stack-on-github-actions-test

格納した後、Lambadへ以下のテストイベントを設定して実行します。

{
  "fileName": "event.json"
}

実行した結果、以下のように正常に動作してS3上のファイルの内容がコンソール上に表示されることが確認できます。

注意事項

上記のコードはプルリクエストへのコード修正時に毎回Docker Hubからイメージを取得しているため、時間がかかるのとDocker Hubの制限に引っかかる可能性はあるので、複数人で並行して開発する場合で問題があればGitHub Actions上でDockerイメージをキャッシュすることを検討してください。

参考資料

LocalStack - A fully functional local AWS cloud stack

How to spinup localstack in CI/CD