Node.jsのAzure FunctionsとAzurite を使用して自動テストを実行する

ゴール

今回のゴールは、Github Actionsでテストを回すことです。
テストはJestで書いており、Blobへの保存などのテストはAzuriteを使います。
AzuriteはDockerで動かして、Github Actionsで起動するコンテナー内にイメージを展開します。

Docs

今回も参考にしたのは公式のドキュメントです。

https://learn.microsoft.com/ja-jp/azure/storage/blobs/use-azurite-to-run-automated-tests

こちらをNode.js(TypeScript)でやるとどうなるかという内容を書いていきます。

ざっくり解説

まず、ソースはこちらにあります。

https://github.com/xiaotiantakumi/azurite-local-sample

テンプレートは例のごとく、自分で用意しているFunctionsのテンプレートを使用しています。

https://github.com/xiaotiantakumi/az-func-ts-starter

公式ドキュメントはPythonでサンプルが書かれています。

今回、TypeScriptでソースコードを記述しました。さらに、公式には記載されていないファイルの確認、アップロード、削除をテストするコードも書いています。公式で提供されていたのは、コンテナが存在するかの確認のみでした。また、GitHub Actionsではなく、Azure Pipelineだったので自分がやりたいこととは少し違いました。というわけで、少しというか、色々書き換えています。

全体の構成

(base) takumi@ ~/Documents/src/private/azure/azurite-local-sample/SampleTrigger$ tree
.
├── blobHelper.ts
├── function.json
└── index.ts

  1. blobHelper.ts: Azure Blob Storageとのやり取りを助ける関数群を定義しています。ファイルのダウンロード、アップロード、削除などの操作を行う関数が含まれています。
  2. function.json: Azure Functionsの設定ファイルで、トリガー、バインディング、その他の関数の設定を定義します。このファイルによって、関数がどのようにトリガーされ、どのように動作するかが決定されます。
  3. index.ts: Azure Functionsのエントリーポイントとなっています。具体的な関数のロジックや、blobHelper.tsからの関数のインポートなどが含まれています。

blobHelper.tsについて

関数の概要

  1. getBlobClient: 指定されたコンテナとファイル名に対応するBlobクライアントを取得します。コンテナが存在しない場合はエラーを返します。
  2. getExistingBlobClient: getBlobClientを使用してBlobクライアントを取得し、ファイルが存在するか確認します。ファイルが存在しない場合はエラーを返します。
  3. downloadStorageItemWithBuffer: 指定されたコンテナとファイル名からファイルをダウンロードし、バッファとして返します。
  4. uploadStorageItemWithBuffer: 指定されたコンテナとファイル名にバッファをアップロードします。成功時には成功メッセージを返します。
  5. deleteStorageItem: 指定されたコンテナとファイル名のファイルを削除します。成功時には成功メッセージを返します。

ポイント

  • index.tsから呼び出される: どこから呼び出されてもいいようにHelper化してますが、今回はindex.tsから呼び出されます。
  • 環境変数の使用: ストレージ接続文字列は環境変数から取得されます。local.setting.jsonで書いてます。以下が今回のものになります。
{
  "IsEncrypted": false,
  "Values": {
    "FUNCTIONS_WORKER_RUNTIME": "node",
    "AzureWebJobsStorage": "",
    "STORAGE_CONNECTION_STRING": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;",
    "DB_HOST": "localhost",
    "DB_NAME": "db",
    "DB_USER": "root",
    "DB_PASSWORD": "root"
  }
}

ところで、STORAGE_CONNECTION_STRINGはAzurite の既定値であり、秘密キーではありません。ということが以下にも書かれているので安心して下さい。

ところで、STORAGE_CONNECTION_STRINGはAzurite の既定値であり、秘密キーではありません。ということが以下にも書かれているので安心して下さい。

https://learn.microsoft.com/ja-jp/azure/storage/blobs/use-azurite-to-run-automated-tests

このコードは、Azure FunctionsとAzure Blob Storageを使用したアプリケーションで、ファイルのCRUD操作を効率的に行うためのヘルパー関数を提供しています。

Github Actionsについて

今回、StorageアカウントのテストをするためにAzuriteの部分に着目して欲しいのですが、まずは全体の流れをソースで見ていきます。

# Docs for the Azure Web Apps Deploy action: https://github.com/azure/functions-action
# More GitHub Actions for Azure: https://github.com/Azure/actions

name: Build and deploy Node.js project to Azure Function App - func-starter-sample

on:
  push:
  workflow_dispatch:

env:
  AZURE_FUNCTIONAPP_PACKAGE_PATH: '.' # set this to the path to your web app project, defaults to the repository root
  NODE_VERSION: '18.x' # set this to the node version to use (supports 8.x, 10.x, 12.x)

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: 'Checkout GitHub Action'
        uses: actions/checkout@v2

      - name: Setup Node ${{ env.NODE_VERSION }} Environment
        uses: actions/setup-node@v1
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: 'Resolve Project Dependencies Using Npm'
        shell: bash
        run: |
            pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}'
            npm install
            npm run build --if-present
            popd
  
      - name: 'Run Docker Compose'
        run: |
            docker compose -f ./docker-compose.yml up -d --wait

      - name: 'Show Docker Compose log'
        run: |
            pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}'
            docker compose logs
            popd

      - name: 'Wait for DB to be ready'
        run: |
            host="localhost"
            dbUser="root"
            dbPass="root"
            until mysql --host=$host --user=$dbUser --password=$dbPass --protocol=TCP -e "SELECT 1"; do
              echo "Waiting for DB to start..."
              sleep 5
            done

      - name: 'Run tests'
        run: |
            pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}'
            npm run test --if-present
            popd

      # これは、Azure Functionsのリソースが作成されていることが前提
      # - name: 'Run Azure Functions Action'
      #   uses: Azure/functions-action@v1
      #   id: fa
      #   with:
      #     app-name: 'func-starter-sample'
      #     slot-name: 'Production'
      #     package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
      #     # set the secret key to your Azure function.
      #     # you can get the secret key after setting cicd in your Azure function.
      #     # once you set the cicd, you can get the secret key in Github Setting pages.
      #     publish-profile: ${{ secrets.xxx }}

ジョブ

  1. リポジトリのチェックアウト: ソースコードをGitHubランナーにチェックアウトします。
  2. Node.jsのセットアップ: 指定されたバージョンのNode.js環境をセットアップします。
  3. プロジェクト依存関係の解決: npm installnpm run buildを使用して、プロジェクトの依存関係を解決し、ビルドします。
  4. Docker Composeの実行: docker-compose.ymlファイルを使用してDockerコンテナを起動します。
  5. Docker Composeログの表示: Dockerコンテナのログを表示します。
  6. DBの準備待ち: MySQLデータベースが起動するのを待ちます。
  7. テストの実行: npm run testを使用してプロジェクトのテストを実行します。
  8. Azure Functions Actionの実行: (コメントアウトされていますが)Azure Functionsへのデプロイを行うステップ。リソースが既に作成されている必要があります。

注意点

  • 最後のステップはコメントアウトしているため、現在はAzureへのデプロイは行われません。
  • データベースの認証情報(ユーザー名とパスワード)がハードコードしているため、テスト目的なら問題ないはずですが、実際に流用するとなった時などにセキュリティ上の懸念があります。

このワークフローは、プロジェクトのビルド、テスト、Dockerコンテナの管理など、CI/CDパイプラインの一般的なタスクをカバーしています。最後のデプロイステップを有効にすると、Azure Functionsへの自動デプロイも可能になります。(Azureだとデプロイセンターでソースコード連携の設定してトークンを取得したりすることで可能になります。)

Github Actionsで呼び出すdocker-compose.ymlについて

まずはソースから
docker-compose.yml

version: "3"

services:
  db:
    image: mysql:latest
    container_name: mysql_db_container
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: db
      TZ: "Asia/Tokyo"
    volumes:
      - ./db/my.cnf:/etc/mysql/conf.d/my.cnf
      - ./db/init:/docker-entrypoint-initdb.d
    ports:
      - "3306:3306"
  azurite:
    image: mcr.microsoft.com/azure-storage/azurite
    ports:
      - "10000:10000"
      - "10001:10001"
      - "10002:10002"
    volumes:
      - ./data:/data

今回ポイントとなるのは、azuriteのイメージです。

image: mcr.microsoft.com/azure-storage/azurite

  • AzuriteのDockerイメージを指定します。このイメージは、Azure Storageのエミュレーションを提供します。

ports

  • Azuriteが使用するポートをホストとコンテナ間でマッピングします。
    • "10000:10000": Blobサービスのポート
    • "10001:10001": Queueサービスのポート
    • "10002:10002": Tableサービスのポート

volumes

  • ホストとコンテナ間でデータボリュームを共有します。
    • ./data:/data: ホストマシンの./dataディレクトリをコンテナの/dataディレクトリにマッピングします。Azuriteがデータを永続化するために使用されます。

テストについて

blobHelper.tsのテストは以下です。

import { mock } from "jest-mock-extended";
import { Context } from "@azure/functions";
import {
  deleteStorageItem,
  downloadStorageItemWithBuffer,
  uploadStorageItemWithBuffer,
} from "../../SampleTrigger/blobHelper";

beforeAll(() => {
  jest.clearAllMocks();
});

const containerName = "container1";
const deleteContainerName = "container2";

test("ファイルダウンロード失敗 - ファイルが存在しない", async () => {
  const context = mock<Context>();
  const fileName = "nonexistent-file.txt";

  const buffer = await downloadStorageItemWithBuffer(
    context,
    containerName,
    fileName
  );
  expect(buffer).toBeUndefined();
  expect(context.res?.status).toBe(404);
});

test("ファイルアップロード失敗 - コンテナが存在しない", async () => {
  const context = mock<Context>();
  const fileName = "sample-file.txt";
  const buffer = Buffer.from("Hello, World!");

  await uploadStorageItemWithBuffer(
    context,
    "nonexistent-container",
    fileName,
    buffer
  );
  expect(context.res?.status).toBe(500);
});

test("ファイル削除失敗 - ファイルが存在しない", async () => {
  const context = mock<Context>();
  const fileName = "nonexistent-file.txt";

  await deleteStorageItem(context, containerName, fileName);
  expect(context.res?.status).toBe(404);
});

test("ファイルダウンロード成功", async () => {
  const context = mock<Context>();
  const fileName = "sample.txt";

  const buffer = await downloadStorageItemWithBuffer(
    context,
    containerName,
    fileName
  );
  expect(buffer).toBeDefined();
});

test("ファイルアップロード成功", async () => {
  const context = mock<Context>();
  const fileName = "sample-file.txt";
  const buffer = Buffer.from("Hello, World!");

  await uploadStorageItemWithBuffer(context, containerName, fileName, buffer);
  expect(context.res?.status).toBe(200);
});

test("ファイル削除成功", async () => {
  const context = mock<Context>();
  const fileName = "sample-file.txt";
  const buffer = Buffer.from("Hello, World!");
  await uploadStorageItemWithBuffer(
    context,
    deleteContainerName,
    fileName,
    buffer
  );
  console.log("uploadStorageItemWithBuffer", context.res);
  const checkExist = await downloadStorageItemWithBuffer(
    context,
    deleteContainerName,
    fileName
  );
  if (!checkExist) {
    throw new Error("ファイルが存在しません");
  }
  await deleteStorageItem(context, deleteContainerName, fileName);
  expect(context.res?.status).toBe(200);
});

上記のテストコードでは、実際にAzuriteを用いてAzure Blob Storageの操作をテストしています。Azuriteは、Azure Storageサービスのエミュレータで、ローカル開発環境でAzure Blob Storage、Queue Storage、Table Storageをエミュレートすることができます。(今回はAzure Blob Storageしか使ってないですが)

以下は、このテストコードのポイントと、Azuriteを使用する利点です。

ポイント

  1. 実際の操作のテスト: モックではなくAzuriteを使用することで、ファイルのダウンロード、アップロード、削除などの実際の操作をテストすることができます。
  2. 成功と失敗の両方のシナリオ: ファイルやコンテナが存在しないなどのエラーケースと、正常な操作の成功ケースの両方をカバーしています。
  3. 状態の検証: 各テストでは、操作後のバッファの状態やHTTPレスポンスステータスなどを検証しています。

Azuriteを使用する利点

  • 開発環境の整合性: Azure Storageの実際の環境と同じように動作するため、開発とテストの間で整合性を保つことができます。
  • コスト削減: ローカルでエミュレートするため、クラウドリソースのコストを削減できます。
  • オフライン開発: インターネット接続がない場所でも開発とテストが可能です。