自動テストを効率的にDockernizeするためのDockerfile構成
「Unitテスト、IntegrationテストコードもDocker化してCI環境で動かそう!」とチームで決定したとします。なんだが簡単にできそうな気がしますが、少し深く考えてみると考慮
事項がいくつかあることが見えてきます。例えば、既にアプリケーションをDockernizeしている場合は、.dockerignore
ファイルを使いテストコードをコンテナから除外しているのではないのかと思います。この記事ではコードベースに極力手を加えず、自動テストをDockernizeする方法を検討します。
# Problem
Jenkins等で自前のCI環境を構築し、複数プロジェクトで共有しているケースは良くあるかと思います。Jenkinsスレーブに各プロジェクトに必要なソフトウェアをインストールしたくない。ポートの競合が起きないようにしたい。といった場合、コンテナ技術はうってつけのソリューションです。ただし自動テストもDockernizeしようとすると追加の考慮次項が発生します。
- Dockerイメージを最小化するため、アプリケーション用コンテナからはテストコードを除外したい
- 開発の生産性を損なわぬようコードべースには余計な手を加えたくない
- Integration、E2Eなど各テスト毎のコンテナ化を効率化したい
NOTE
この記事のサンプルコードはGithub で確認できます。
# Solution
コードベースに余計な手を加えないためにも、Dockerの機能だけで解決することがポイントになります。具体的にはDockerfileと.dockerignoreを実行対象単位で分割することで実現します。加えて、テストの種別毎にこれらのセットを用意したくないので、自動テストのDockerfileはmulti stage buildsを利用することで管理コストを最小化します。
このソリューションはStack overflowを参考にしています
# 最終ディレクトリ構成
サンプルのアプリケーションはNodejsのExpressフレームワークの簡単なREST APIがあり、JestテストフレームワークでUnitテスト、Integrationtテストを実装しています。Nodejsに依存していないので、他の言語でも流用可能なソリューションです。
対象イメージ毎に.dockerignoreファイルをわけるためには、$DOCKERFILE_NAME.docerignore
という命名規則でファイルを配置することで、docker build -f $DOCKERFILE_NAME ・・・
でイメージを作成すると命名規則に応じた.dockerignoreファイルがピックアップされます。
最終構成は以下のようになります。
.
├── app.js
├── docker-build-and-run.sh
├── Dockerfile.app
(for application)
├── Dockerfile.app.dockerignore
(for application)
├── Dockerfile.test
(for automation-test (multi-staged))
├── Dockerfile.test.dockerignore
(for automation-test)
├── integration-test
├── lib
├── node_modules
├── package.json
├── package-lock.json
└── Readme.md
自動テスト用のDockerfileはmulti stage builds
を利用することで管理コストを最小化しています。
FROM node:12.14.0-alpine3.11 AS node
# -- base
FROM node AS base
COPY . /opt/app
WORKDIR /opt/app
RUN npm install
# -- unit
FROM base AS unit
COPY --from=base /opt/app /opt/app
WORKDIR /opt/app
ENTRYPOINT ["npm", "run", "test:unit"]
# -- integration
FROM base AS integration
COPY --from=base /opt/app /opt/app
WORKDIR /opt/app
ENTRYPOINT ["npm", "run", "test:integration"]
# -- Default target
FROM base
Dockernizeされた自動テストの実行結果は以下のようになります。
# run unit test
$ bash docker-build-and-run.sh unit
・
・
Successfully built 78fccf9aa415
Successfully tagged 3code/unit-test:latest
> 3code-Tech-Blogcode-exaples_dockernize-test-automation@1.0.0 test:unit /opt/app
> jest --testPathIgnorePatterns integration-test/
PASS lib/__tests__/sum.spec.js
sum function
✓ simple calc (4ms)
✓ simple calc with negative number (1ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 0.749s
Ran all test suites.
# run integration test
$ bash docker-build-and-run.sh integration
・
・
Successfully built 481fa0bfe4b9
Successfully tagged 3code/intregration:latest
> 3code-Tech-Blogcode-exaples_dockernize-test-automation@1.0.0 test:integration /opt/app
> jest integration-test
PASS integration-test/__tests__/sum-endpoint.spec.js
sum endpoint
✓ simple calculation (37ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.778s
Ran all test suites matching /integration-test/i.
各Dockerイメージのサイズを見ると、アプリケーション用のイメージからは不要なファイルが除外されていることがわかります。
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
3code/intregration latest 481fa0bfe4b9 14 minutes ago 161MB
3code/unit-test latest 78fccf9aa415 14 minutes ago 161MB
3code/application
latest 0dbef66b6983 18 minutes ago 123MB
node 12.14.0-alpine3.11 1cbcaddb8074 10 days ago 85.2MB
# Discussion
本記事はDockerfile構成のコンセプトを説明するのが目的のため、サンプルコード のテストコードは必要最低限のものになっています。IntegrationテストではREST APIをDockerコンテナ内で実行しているため、ポート競合などホストOSに影響を与えることなくテストを実行することができます。
以下がIntegrationテストコードです。あまり適切ではありませんが、Setup and Teardonwを使いREST APIの起動、停止を行っています。
const axios = require('axios')
const { spawn } = require('child_process')
// Unrecommended setup and teardown
let proc;
beforeAll(async () => {
proc = spawn("node", [`${process.cwd()}/app.js`])
await new Promise((resolve) => {setTimeout(() => {resolve()}, 2000)})
});
afterAll(() => {
proc.kill('SIGTERM')
});
describe('sum endpoint', () => {
test('simple calculation', async () => {
const [a, b] = [1, 2]
const ret = await axios.post('http://localhost:3000/sum', {a, b})
expect(ret.data).toEqual({result: a +b})
})
})
現実のアプリケーションではDocker Compose等を利用し、DBなどのバックエンドサービスをあわせて起動することになると思います。より現実に近い自動テストのDockernizeについても別の記事で紹介する予定です。
# .dockerignoreの詳細
アプリケーションのイメージからはテストコードを除外しています。各自動テストのイメージはUnitテスト、Integrationテストで同じDockerfileを利用しています。Dockerfile、 .dockerignoreをさらに細分化することで、それぞれのテストに必要なテストコードのみイメージに焼きこむことも可能ですが、管理コストとのトレードオフで自動テストは1つのDockerfileにまとめ、multi stage builds
の恩恵を受けられるようにしています。
# アプリケーション用
**/__tests__
README.md
env.*
.git
.gitignore
package-lock.json
package.json
# 自動テスト用
README.md
env.*
.git
.gitignore
package-lock.json
package.json
NOTE
Jesテストフレームワークでは、__tests__
ディレクトリにテストコードを配置するのが慣習です
# multi stage buildsを利用している理由
本サンプルではあまりメリットがありませんが、例えばE2EテストもDockernizeするとした場合、E2E用のstageを作成し、Selenimu用のヘッドレスブラウザーがインストールされたDockerイメージを作るなどの拡張が可能です。
multi stage buildsのDockerfileは以下のようにビルドすることが可能です。
# unit test
docker build -f Dockerfile.test --target unit . -t $TAG
# integration test
docker build -f Dockerfile.test --target integration . -t $TAG
# Dcoekr Composeでの利用
Dcoker Composeのbuildオプションを利用することで、ビルドと実行を一括で行うことが可能です。以下のようなCompose fileを用意し、$ dcoker-compose -f $COMPOSE_FILE run [unit|integration]
とすることで、Dockernizeされた自動テストを実行することができます。Docker Composeが使える環境であればより現実的なオプションになります。
version: '3.7'
services:
unit:
build:
context: .
dockerfile: Dockerfile.test
target: unit
container_name: unit-test
integration:
build:
context: .
dockerfile: Dockerfile.test
target: integration
container_name: integration-test
WARNING
CI環境で実行する場合、CI環境にDocker Composeをインストールする必要があります
# Alternative Patterns
単純にフォルダを切ることでも実現可能です。好みの問題になりますが、個人的にはDockerfileはルートディレクトリの直下にあるほうが直観的である点と、ディレクトリを掘り出すとディレクトリ名や階層についての考慮次項が増えるので、前述の方法を紹介しました。
.
└── dockerfiles
├── app
│ ├── Dockerfile
│ └── .dockerignore
└── auto-test
├── Dockerfile
└── .dockerignore
# Conclusion
本記事では自動テストを効果的にDockerlizeする方法を紹介しました。アプリケーションだけでなく、自動化プロセスや、Adminプロセスもどんどんコンテナ化しDevOpsを推し進めていきましょう!