CodeBuildを使ってビルド・テストをしてみる

スポンサーリンク
ハンズオン
スポンサーリンク

はじめに

AWSを使ってCI/CDを始めてみたいけど、『何から始めたらいいのかわからない』『どんな準備が必要なの?』と悩む方も多いのではないでしょうか?
この記事では、AWSを使ったCI/CDのはじめの一歩として、CodeBuildを使ってビルド・テストをする方法を解説していきます。
この記事を読むと、CodeBuildを使ったビルドの実行、テスト実行とテストレポートの確認方法について学べます。

用語解説

本記事で登場する用語について事前に解説します。

CI/CDとは?

CI/CDは、以下の2つのプロセスを組み合わせたものです。
CI (継続的インテグレーション)
開発者が頻繁にコードをリポジトリに統合するプロセスで、コード変更が自動的にビルドおよびテストされます。これにより、バグの早期発見が可能になります。
CD (継続的デプロイ)
ビルドされたコードを自動的にデプロイするプロセスで、本番環境やステージング環境へのリリースが迅速かつ安定して行われます。
以下CI/CDの一般的な流れです。
  1. コードの変更:開発者がコードを変更し、リポジトリにプッシュします。
  2. 自動ビルドとテスト (CI):CodeBuildなどのツールがコードをビルドし、自動テストを実行します。
  3. デプロイ (CD):CodeDeployがビルドされたコードをターゲット環境にデプロイします。
CI/CDプロセスを取り入れることで、開発からリリースまでの時間が短縮され、リリース品質も向上します。

buildspecとは?

buildspecはCodeBuildで利用するビルドの設計書です。このファイルに記載された指示に従ってCodeBuildが動作します。
形式はyamlもしくはjsonです。
環境変数の定義、パッケージのインストール、ビルド、テスト、ビルドしたファイルのzip化などの後処理をbuildspecに定義します。

jestとは?

Jestは、JavaScriptのコードテストを行うためのテストフレームワークです。
テスト結果やテストのコードカバレッジをレポートに出力できます。
今回の記事ではjestを使ってテストコードを作成します。

環境準備の手順

環境構成

本記事で紹介するハンズオンの環境構成は以下の通りです。
動作の概要としては以下になります。
  1. CodeBuildでビルドを実行し、CodeCommitからソースを取得
  2. ビルド・テストを実施
  3. ビルドしたファイルをzip化しS3にアップロード

S3バケットの作成

S3バケットを作成します。

アーティファクト(ビルドしたファイル)格納用のS3バケットの作成

ビルドした成果物を格納するS3バケットを作成します。
S3の画面を開き、バケットを作成します。バケットを作成を選択してください。
バケット名を入力してバケットを作成します。

CodeCommitでリポジトリを作成

CodeCommitでコード管理用のリポジトリを作成します。

リポジトリの作成

CodeCommitの画面を開き、リポジトリを作成します。リポジトリを作成を選択してください。
「リポジトリを作成」画面が表示されます。リポジトリ名と説明(任意)を入力して、作成を選択してください。
リポジトリができました。

リポジトリの設定

CodeCommitとHTTPSで通信できるように認証用のユーザを作成し、clone、pushします。
以下の記事を参考にリポジトリのユーザ設定・初期設定を行ってください。
readme.mdを作成してpushまでできれば初期設定は完了です。

CodeBuildの設定

CodeBuildでビルドの設定します。
CodeBuildの画面を開き、プロジェクトを作成します。プロジェクトの作成を選択してください。

プロジェクトの作成画面が表示されます。
以下を参考に設定してください。


※今回は紹介のためmasterブランチを使ってます。実際のビルドの計画に合わせてブランチ名を修正してください。

ソース・テストコードの作成

Node.jsで動作するLambda関数用のプログラムをTypeScriptで作成します。
※内容はAPI GatewayのリクエストからS3バケットにテキストファイルをアップロードするプログラムです。
ビルドとユニットテスト(jest)をできるように設定します。

開発環境のセットアップ


環境のセットアップを飛ばしたい方
以下コマンドでソースとテストコードを取得できます。
.gitフォルダ以外をフォルダ・ファイルをCodeCommitのローカルリポジトリにコピーして、リモートリポジトリにプッシュしてください。
git clone https://github.com/TeTeTe-Jack/aws-handson-s3-text-uploader.git

実際に手を動かしてみたい方
下記の手順を実施してください。
手順を実施すると以下のフォルダ構成となります。
/
├─ __test__ 
│  ├─ handler.spec.ts
│  └─ setEnv.ts
├─ coverage      // カバレッジの出力で作成される テストをするまではかからフォルダ
│  └─ (カバレッジの出力ファイル)
├─ dist          // ビルドの実行で作成される ビルドをするまではかからフォルダ
│  └─ (ビルドの成果物ファイル)
├─ node_modules
│  └─ (各外部モジュール)
├─ report        // テストの実行で作成される テストをするまではかからフォルダ
│  └─ (テストの結果ファイル)
├─ src
│  └─ handler.ts
├─ .gitignore
├─ buildspec.yml
├─ jest.config.ts
├─ package-lock.json
├─ package.json
├─ readme.md
└─ tsconfig.json
Node.jsの環境をセットアップしてください。(参考:Node.jsのダウンロードサイト
package.jsonにビルド用とテスト用のスクリプトを追加します。

開発用のフォルダを作成しnodeの初期設定します。

npm init -y
package.jsonにビルド用とテスト用のスクリプトを追加します。
"scripts": {
  "build": "tsc",
  "test": "jest --coverage"
},
必要なパッケージのインストールをします。
npm install --save-dev typescript @types/aws-lambda @aws-sdk/client-s3 @types/jest jest ts-jest ts-node jest-junit
typescriptの初期設定をします。
npx tsc --init
少し設定を変えて以下で設定ファイルを定義します。
修正ファイル:tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022", 
    "module": "commonjs",
    "outDir": "./dist/",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": [
    "src/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}
jestの初期設定をします。
npx jest --init
テスト用の設定追加します。
修正ファイル:jest.config.json
module.exports = {
  reporters: [
    'default',
    [ 'jest-junit', {
      outputDirectory: "report",
      outputName: "report.xml",
    } ]
  ],
  transform: {
    "^.+\\.tsx?$": "ts-jest",
  },
  moduleFileExtensions: ["ts", "tsx", "js"],
  testEnvironment: "node",
  globalSetup: "/__test__/setEnv.ts",
};
CodeBuildで利用するbuildspec.ymlファイルを作成します
version: 0.2
phases:
  install:
    commands:
    - echo "Install started on `TZ=-9 date '+%Y/%m/%d %H:%M:%S JST'`"
    - echo "Install ended on `TZ=-9 date '+%Y/%m/%d %H:%M:%S JST'`"
  pre_build:
    commands:
      - echo "Pre-Build started on `TZ=-9 date '+%Y/%m/%d %H:%M:%S JST'`"
      - npm ci
      - echo "Pre-Build ended on `TZ=-9 date '+%Y/%m/%d %H:%M:%S JST'`"
  build:
    commands:
      - echo "Build started on `TZ=-9 date '+%Y/%m/%d %H:%M:%S JST'`"
      - npm run build
      - npm run test
      - echo "Build ended on `TZ=-9 date '+%Y/%m/%d %H:%M:%S JST'`"
  post_build:
    commands:
      - echo "Post-Build started on `TZ=-9 date '+%Y/%m/%d %H:%M:%S JST'`"
      - echo "Post-Build ended on `TZ=-9 date '+%Y/%m/%d %H:%M:%S JST'`"
reports:
  jest_reports:
    files:
      - report.xml
    file-format: JUNITXML
    base-directory: report
  coverage_reports:
    files:
      - clover.xml
    file-format: CLOVERXML
    base-directory: coverage
artifacts:
  files:
    - ./**/*
  base-directory: dist
.gitignoreファイルを作成します。
# 外部モジュールの格納先
/node_modules

# ビルドしたファイルの格納先
/dist

# テストのレポートの格納先
/report

# コードカバレッジのレポートの格納先
/coverage
ソースとテスト用のフォルダを作成
mkdir src
mkdir __test__
以下のフォルダ構成になります。
/
├─ __test__ 
│  └─ (テストコード)
├─ coverage      // カバレッジの出力で作成される
│  └─ (カバレッジの出力ファイル)
├─ dist          // ビルドの実行で作成される
│  └─ (ビルドの成果物ファイル)
├─ node_modules
│  └─ (各外部モジュール)
├─ report        // テストの実行で作成される
│  └─ (テストの結果ファイル)
├─ src
│  └─ (ソースファイル)
├─ .gitignore
├─ buildspec.yml
├─ jest.config.ts
├─ package-lock.json
├─ package.json
├─ readme.md
└─ tsconfig.json

ソースとテストコード

以下のファイルを作成します。
ソースファイル:src/handler.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { APIGatewayProxyHandlerV2 } from "aws-lambda";

const s3Client = new S3Client({ region: process.env.AWS_REGION });
const BUCKET_NAME = process.env.BUCKET_NAME;

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
    try {
        // Validate query parameters
        const { queryStringParameters } = event;
        if (!queryStringParameters) {
            return {
                statusCode: 400,
                body: JSON.stringify({ message: "Missing query parameters" }),
            };
        }

        const { filename, content } = queryStringParameters;

        if (!filename || !content) {
            return {
                statusCode: 400,
                body: JSON.stringify({ message: "Both 'filename' and 'content' parameters are required" }),
            };
        }

        // Upload content to S3
        const uploadParams = {
            Bucket: BUCKET_NAME,
            Key: filename,
            Body: content,
            ContentType: "text/plain",
        };

        await s3Client.send(new PutObjectCommand(uploadParams));

        return {
            statusCode: 200,
            body: JSON.stringify({
                message: "File uploaded successfully",
                filename,
            }),
        };
    } catch (error) {
        console.error("Error uploading file to S3:", error);

        return {
            statusCode: 500,
            body: JSON.stringify({
                message: "Internal Server Error",
                error: error instanceof Error ? error.message : "Unknown error",
            }),
        };
    }
};
テスト用の環境変数設定ファイル:__test__/setEnv.ts
export default (): void => {
    console.log("\nSetup test environment");
    process.env.AWS_REGION = "ap-northeast-1";
    process.env.BUCKET_NAME = "test-bucket";
    console.log("process.env.AWS_REGION", process.env.AWS_REGION);
    console.log("process.env.BUCKET_NAME", process.env.BUCKET_NAME);
};
テスト用の環境変数設定ファイル:__test__/handler.spec.ts
import { handler } from "./../src/handler";
import { S3Client } from "@aws-sdk/client-s3";
import { Context, APIGatewayProxyResult } from "aws-lambda";

jest.mock("@aws-sdk/client-s3", () => {
    const mockS3Client = {
        send: jest.fn(),
    };
    return {
        S3Client: jest.fn(() => mockS3Client),
        PutObjectCommand: jest.fn((params) => params),
    };
});

describe("Lambda Function - Upload to S3", () => {
    const mockS3Client = new S3Client({});
    const mockEnv = process.env;

    beforeEach(() => {
        jest.clearAllMocks();
        process.env = { ...mockEnv, BUCKET_NAME: "test-bucket" };
    });

    afterAll(() => {
        process.env = mockEnv;
    });

    const mockContext: Context = {
        awsRequestId: "test-request-id",
        callbackWaitsForEmptyEventLoop: false,
        functionName: "test-function",
        functionVersion: "$LATEST",
        invokedFunctionArn: "arn:aws:lambda:us-east-1:123456789012:function:test-function",
        logGroupName: "/aws/lambda/test-function",
        logStreamName: "test-log-stream",
        memoryLimitInMB: "128",
        getRemainingTimeInMillis: () => 3000,
        done: () => {},
        fail: () => {},
        succeed: () => {},
    } as Context;

    it("should return 400 if query parameters are missing", async () => {
        const event = { queryStringParameters: null } as any;

        const result = (await handler(event, mockContext, () => {})) as APIGatewayProxyResult;

        expect(result.statusCode).toBe(400);
        expect(result.body).toContain("Missing query parameters");
    });

    it("should return 400 if 'filename' or 'content' is missing", async () => {
        const event = {
            queryStringParameters: { filename: "test.txt" },
        } as any;

        const result = (await handler(event, mockContext, () => {})) as APIGatewayProxyResult;

        expect(result.statusCode).toBe(400);
        expect(result.body).toContain("Both 'filename' and 'content' parameters are required");
    });

    it("should upload file to S3 and return 200", async () => {
        const event = {
            queryStringParameters: {
                filename: "test.txt",
                content: "Hello World",
            },
        } as any;

        (mockS3Client.send as jest.Mock).mockImplementation((command) => {
            return Promise.resolve(command.input);
        });

        const result = (await handler(event, mockContext, () => {})) as APIGatewayProxyResult;

        expect(result.statusCode).toBe(200);
        expect(result.body).toContain("File uploaded successfully");
        expect(mockS3Client.send).toHaveBeenCalledWith(
            expect.objectContaining({
                Bucket: "test-bucket",
                Key: "test.txt",
                Body: "Hello World",
            })
        );
    });
    it("should return 500 if S3 upload fails", async () => {
        const event = {
            queryStringParameters: {
                filename: "test.txt",
                content: "Hello World",
            },
        } as any;

        (mockS3Client.send as jest.Mock).mockRejectedValueOnce(new Error("S3 upload error"));

        const result = (await handler(event, mockContext, () => {})) as APIGatewayProxyResult;

        expect(result.statusCode).toBe(500);
        expect(result.body).toContain("Internal Server Error");
        expect(result.body).toContain("S3 upload error");
    });
    it("should return 500 if unkwon error", async () => {
        const event = {
            queryStringParameters: {
                filename: "test.txt",
                content: "Hello World",
            },
        } as any;

        (mockS3Client.send as jest.Mock).mockRejectedValueOnce("test");

        const result = (await handler(event, mockContext, () => {})) as APIGatewayProxyResult;

        expect(result.statusCode).toBe(500);
        expect(result.body).toContain("Internal Server Error");
        expect(result.body).toContain("Unknown error");
    });
});

CodeCommitにpush

以下のコマンドでCodeCommitに設定ファイルやソースファイルをpushします。
git add *
git commit -m "add setting files and source files"
git push

動作確認

CodeBuildの画面を開きます。
左メニューのビルドプロジェクトを選択します。

作成したプロジェクトを選択します。

ビルドを開始を選択します

ビルドを開始するとビルドログが表示されます。エラーなどが発生した場合はこちらのログを確認してください。

フェーズ詳細タブを選択するとビルドがどこまで進んでいるかわかります。COMPLETEが成功となっていればビルド完了です。

レポートタブでテスト結果を確認することができます。今回はテストとテストのコードカバレッジをレポートに出力しています。

テストの結果から確認していきます。

今回の結果はテストが100%クリアとなってます。各テストケースで出力メッセージを確認することができるので失敗時に参考にしてみてください。

続いてコードカバレッジを確認していきます。

ラインカバレッジとブランチカバレッジがともに100%となりました。

最後にビルド成果物(アーティファクト)の確認をします。S3の画面を開き汎用バケットを選択してください。

S3バケット一覧が表示されるので、アーティファクト格納用で作成したS3バケットを選択します。

artifact.zipが格納されています。こちらのzipファイルを使ってLambda関数のコードの更新が可能です。

zipファイルをダウンロードして、解凍してみると、JavaScriptに変換されたファイルが格納されていました。

まとめ

この記事では、AWSを使ったCI/CDのはじめの一歩として、CodeBuildを使ってビルド・テストをする方法を解説しました。
CodeBuildを使ったビルドやテストについて流れをつかめたのではないかと思います。

今回ビルド・テストの方法を応用することで、CodeCommitのイベントをトリガーにして、ビルドやデプロイをすることも可能です。

より詳しく知りたい方は以下の書籍がおすすめです。

[商品価格に関しましては、リンクが作成された時点と現時点で情報が変更されている場合がございます。]

徹底攻略AWS認定デベロッパー – アソシエイト教科書&問題集 第2版 [DVA…
価格:3,960円(税込、送料無料) (2024/9/28時点)

楽天で購入

 

タイトルとURLをコピーしました