CloudWatch Syntheticsを使ってE2Eテストの導入をガチに検討してみる

## はじめに
CloudWatch Syntheticsを使って外型監視を実装した時に、
E2Eテストにも流用できるのではと思いガチ目にデモを実装してみました。

Syntheticsはローカルでの実行ができないのでどのように開発、デプロイを行うかが今回の課題でした。


## CloudWatch Syntheticsについて
- AWSが提供するWebアプリケーション、APIを簡単に監視できるサービス。
- SyntheticsはPuppeterを裏側で使用しておりPuppeterの記述でテストシナリオを書くことができる。
- CloudWatch Alarmと連携させ監視を容易に実現することできる。

[CloudWatch Syntheticsについて](https://pages.awscloud.com/rs/112-TZM-766/images/AWS-Black-Belt_2023_Amazon-CloudWatch-Synthetics_0331_v1.pdf)

## E2Eとして導入する上での問題点
- SyntheticsはLambda LayerとしてAWSで提供されているモジュールなのでローカルで実行ができない。
- コンソールからスクリプトを直書きすれば動作はするがシナリオ開発時にAWSリソースの作成を行うのは手間。

## 解決方法
- ローカルのテストはPuppeteerをnodejsを実行しながら開発を行えるようにする。
- 一つのテストスクリプトでローカル実行とSyntheticsの実行を行えるようにする。
- Syntheticsでの実行はcdkでリソースをデプロイする仕組みを作る。

## 基本構成
下記の構成でプロジェクトを作成しました。
cdkにはSyntheticsのリソースを作成するためのコードを管理。
scriptsにはCanaryとなるLambdaを管理。

```
├── cdk
│   ├── bin
│   │   └── cdk.ts
│   ├── cdk.json
│   ├── jest.config.js
│   ├── lib
│   │   └── scenario
│   │       └── TestStack.ts
├── docker
│   ├── Dockerfile
│   └── docker-compose.yaml
└── scripts
    ├── index.ts
    ├── lib
    │   ├── LocalLogger.ts
    │   ├── LocalTesting.ts
    │   └── TestingFactory.ts
    ├── package.json
    ├── scenario
    │   └── test
    │       ├── test.spec.ts
    │       └── index.ts
    ├── screenshots
```

## 環境構築について
Dockerでaws-cdkとawscliを予めグローバルインストールさせた環境を作りました。
``` Dockerfile
FROM node:20.10

RUN set -x && \
    apt-get update && \
    apt-get install -y software-properties-common \
    curl unzip libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
    libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 \
    locales fonts-ipafont fonts-ipaexfont && \
    echo "ja_JP.UTF-8 UTF-8" > /etc/locale.gen  && locale-gen

RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \
    unzip awscliv2.zip && \
    ./aws/install

RUN yarn global add puppeteer aws-cdk@2.115.0 && \
    npx puppeteer browser install chrome

ENV PATH="/usr/local/bin:${PATH}"

WORKDIR /app

CMD ["tail", "-f", "/dev/null"]
```
``` docker-compose.yaml
version: '3'

services:
  e2e-test-boilerplate:
    build: .
    env_file:
      - .env.local
    volumes:
      - ..:/app
```

## ローカルでの実行について
ローカル実行用にLoggerとSyntheticsのWrapperを作成し、
Factoryにてモジュール生成するようにしました。
```scripts/lib/LocalTesting.ts
import puppeteer, { Browser, Page } from "puppeteer";
import LocalLogger from "./LocalLogger";

export default class LocalTesting {
  private browser: Browser | null;
  private page: Page | null;
  private logger: LocalLogger;

  constructor() {
    this.browser = null;
    this.page = null;
    this.logger = new LocalLogger();
  }

  /**
   * ブラウザの初期化
   */
  private async initialize() {  
    this.browser = await puppeteer.launch({
      headless: 'new',
      args: [
        "--no-sandbox",
        "--disable-setuid-sandbox",
      ],
    });
    this.page = await this.browser.newPage();
  }

  /**
   * ブラウザを取得
   * @returns Browser
   */
  public async getPage() {
    if (!this.page) {
      await this.initialize();
    }
    return this.page;
  }

  /**
   * ステップ実行
   * @param step string
   * @param callback Promise<void>
   */
  public async executeStep(step: string, callback: () => Promise<void>) {
    this.logger.info(`Executing step: ${step}`);
    try {
      await callback();
    } catch (e) {
      this.logger.error(`Error executing step: ${step}`, e);
    }
    await this.page?.screenshot({path: `./screenshots/${step}.png`})
    this.logger.info(`Finished step: ${step}`);
  }

  public async finish() {
    this.logger.info("Close local test");
    await this.browser?.close();
  }
}
```

```scripts/lib/LocalLogger.ts
import * as winston from 'winston';


export default class LocalLogger {
    private logger: winston.Logger;

    constructor() {
        this.logger = winston.createLogger({
            level: process.env.LOG_LEVEL || 'info',
            format: winston.format.simple(),
            transports: [
              new winston.transports.Console()
            ]
        });
    }

    /**
     * infoログを出力
     * @param message 
     */
    public info(message: string): void {
        this.logger.info(message);
    }

    /**
     * errorログを出力
     * @param message 
     * @param e 
     */
    public error(message: string, e: unknown): void {
        this.logger.error(message, e);
    }
}

```
```scripts/lib/TestingFactory.ts
import LocalTesting from "./LocalTesting";
import LocalLogger from "./LocalLogger";

export const TestingFactory = {
  createTesting: (): any => {
    try {
      return require('Synthetics');
    } catch (e) {
      return new LocalTesting();
    }
  },
  finishTest:async (testing:any) => {
    if (testing instanceof LocalTesting) {
      await testing.finish();
    }
  },
  executeTestIfLocal:async (scenario: () => Promise<void>) => {
    if (process.env.APP_ENV === 'local') {
      await scenario();
    }
  },
  createLogger: (): any => {
    try {
      return require('SyntheticsLogger');
    } catch (e) {
      return new LocalLogger();
    } 
  }
}

```
## Canaryの実装について
実装したTestingFactoryを使用して、syntheticsとloggerを生成する記述にします。
Puppeteerはbrowserを終了させる必要があるので終了処理の追加しました。
ファイル単体で実行できるようにローカル環境の場合はscriptを実行するようにしてます。

それ以外はChrome拡張のSynthetics　Recorderでで生成したものになります。


```scripts/scenario/test/test.spec.ts
import { TestingFactory } from "../../lib/TestingFactory";

const synthetics = TestingFactory.createTesting();
const logger = TestingFactory.createLogger();

const url = 'https://example.com/';

const testName = 'ExampleTest';

const testScript = async function () {
  logger.info(`Start script: ${testName}`);

  const page = await synthetics.getPage();
  const navigationPromise = page.waitForNavigation()
  
  await synthetics.executeStep(`${testName} step1`, async function() {
    await page.goto(url, {waitUntil: 'domcontentloaded', timeout: 60000})
  })
  
  await synthetics.executeStep(`${testName} step2`, async function() {
    await page.waitForSelector('.navi-body > .cat-it > .navi-link > .navi-link-text > span')
    await page.click('.navi-body > .cat-it > .navi-link > .navi-link-text > span')
  })
  
  await navigationPromise
  
  await synthetics.executeStep(`${testName} step3`, async function() {
    await page.waitForSelector('#container > .navi-page > .navi-page-mode > li:nth-child(2) > a')
    await page.click('#container > .navi-page > .navi-page-mode > li:nth-child(2) > a')
  })
  
  await navigationPromise

  await synthetics.executeStep(`${testName} step4`, async function() {
    await page.waitForSelector('.entrylist-header-main > .cat-it > .entrylist-contents > .entrylist-contents-main > .entrylist-contents-users > .js-keyboard-entry-page-openable')
    await page.click('.entrylist-header-main > .cat-it > .entrylist-contents > .entrylist-contents-main > .entrylist-contents-users > .js-keyboard-entry-page-openable')
  })
  
  await navigationPromise

  logger.info(`End script: ${testName}`);
  await TestingFactory.finishTest(synthetics);
}

TestingFactory.executeTestIfLocal(testScript);

export default testScript;

```

lambdaとして使用するためのHandlerファイルを同階層に作成し
上階層のindex.tsで別名でexportしてます。(シナリオの追加ができるように）

``` scripts/scenario/test/index.ts
import LoginTest from './testspec'

export const handler = async () => {
  return await LoginTest();
}
```
``` scripts/index.ts
export { handler as testHandler } from './scenario/test/index';
```

## ローカルでの実行とソースのビルドについて
package.jsonを下記のように記述し
yarn test scripts/scenario/test/test.spec.tsでローカルでPuppeterが起動します。
scripts/screenshotsにステップ毎のスクリーンショットが保存されます。

yarn buildでbundle.jsがdistディレクトリに作成されます。
ビルドにはesbuildを使用しました。

```scripts/package.json
{
  "name": "e2e-test-boilerplate",
  "version": "1.0.0",
  "main": "index.ts",
  "license": "MIT",
  "scripts": {
    "build": "esbuild index.ts --bundle --platform=node --bundle --outfile=dist/nodejs/node_modules/bundle.js",
    "test": "npx ts-node",
    "lint": "eslint . --ext .ts",
    "lint:fix": "eslint . --ext .ts --fix"
  },
  "dependencies": {
    "@types/puppeteer": "^7.0.4",
    "puppeteer": "^21.6.1",
    "winston": "^3.11.0"
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^6.15.0",
    "@typescript-eslint/parser": "^6.15.0",
    "esbuild": "^0.19.10",
    "eslint": "^8.56.0",
    "ts-loader": "^9.5.1",
    "ts-node": "^10.9.2",
    "typescript": "^5.3.3"
  }
}

```

## cdkについて
lib直下にsyntheticsリソースを作成するファイルを記述
codeにシナリオをビルドしたパスを指定し、handlerにexportしたhandlerを指定。

```cdk/lib/scenario/TestStack.ts
import * as path from 'path';
import { Stack, StackProps } from 'aws-cdk-lib';
import * as synthetics from 'aws-cdk-lib/aws-synthetics';
import { Construct } from 'constructs';

const testName = 'test';

export class TestStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    new synthetics.Canary(this, `${process.env.APP_ENV}-${process.env.APP_NAME}-${testName}-canary`, {
      canaryName: `${process.env.APP_ENV}-${testName}`,
      schedule: synthetics.Schedule.once(),
      test: synthetics.Test.custom({
        code: synthetics.Code.fromAsset(path.join(__dirname, '../../../scripts/dist')),
        handler: 'bundle.testHandler',
      }),
      runtime: synthetics.Runtime.SYNTHETICS_NODEJS_PUPPETEER_6_0,
      environmentVariables: {
        APP_ENV: process.env.APP_ENV || 'develop',
        LOG_LEVEL: process.env.LOG_LEVEL || 'info',
      },
    });
  }
}

```
cdkのエントリポイントのbin直下で読み込み
あとはAPP_ENVをlocal以外にしてcdk deployしてリソースが作成し実行できれば完成です。

```cdk/bin/cdk.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { TestStack } from '../lib/scenario/TestStack';

const app = new cdk.App();
new TestStack(app, 'CdkE2EStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  }
});

```

## 今後の改善点
- LocalTestingがまだ薄いWrapperなので今後拡張していきたい。
- jestと絡めてassert機能を入れてみたい。
- GithubActionsでcdkデプロイを自動化したい。
- FactoryでAny型を許容してしまっているので型を明確にしたい。


## 最後に
E2Eテストを一から構築し開発フローに乗せるのはかなり大規模な構築になるところが、
CloudWatch Syntheticsを使うことで最小の工数で実現できたのでかなり手応えを感じました。
この記事がE2Eテスト導入の閾を下げることに貢献できたら幸いです。

[今回のGithubリポジトリはこちら](https://github.com/ShotaWeb3/e2e-test-boilerplate)