🎭

Playwright + Amazon ECSでE2Eテストが秒で廃墟になる問題を解決する

に公開
33

はじめに

こんにちは!
株式会社エクスプラザのhyodoです!

E2Eテストの自動化を導入して3ヶ月後、こんな会話が聞こえてきたことはありませんか?

「このテスト、また落ちてるけど誰か見てる?」
「あー、それいつも落ちるやつだから無視して大丈夫」

自動テストがあるのに誰も信用していない。書いた本人しかメンテできない。エンジニアのPCでしか動かない。——結局、数ヶ月で誰も触らなくなる。

これ、自動化の「やり方」ではなく「届け方」に問題があるケースが多いです。

今回は、QAメンバーやPMがブラウザからボタンひとつでE2Eテストを実行・結果確認できる環境をPlaywrightとAWS ECSで構築したので、その設計と実装を紹介します。

なぜE2Eテストは廃墟になるのか

自動テストが使われなくなる原因は、だいたい以下の3つに集約されます。

テスト結果を誰も信用しない。 HTMLの構造をちょっと変えただけで落ちる。サーバーが重いとランダムに失敗する。こうなると「赤くなってるけど本当のバグか分からない」状態になり、テスト結果を見なくなります。

テストの修正コストが高い。 仕様変更のたびに大量のテストファイルを修正する必要がある設計だと、「直すくらいなら手動で確認したほうが早い」となります。

テストを実行できる人が限られている。 ローカルPCにNode.jsとPlaywrightとブラウザをセットアップして、ターミナルでコマンドを叩く——この時点でエンジニア以外はお手上げです。QAやPMは「テスト結果どうだった?」とエンジニアに聞くしかなく、テストの存在感が薄れていきます。

1つ目と2つ目はテストの「書き方」の問題で、Playwrightの機能でほぼ解決できます。3つ目は「届け方」の問題で、ここにECSが効いてきます。

テスト自動化で何ができて、何ができないのか

E2Eテストの自動化に手を出す前に、自動化で得られるものと得られないものを整理しておきます。ここをぼんやりさせたまま進めると「思ったほど効果が出ない」「コスト見合わない」という話になりやすいです。

自動化で得られるのは、反復的な手動テストの削減、テスト実行のたびに同じ手順を正確に再現できること、ソフトウェアの改修で既存機能が壊れていないかを継続的にチェックできること(リグレッションテスト)、などです。

一方で、すべての手動テストを自動化できるわけではありません。自動テストで検証できるのはテストツールがサポートする操作に限られますし、検証の範囲も「事前に定義した期待結果」との一致確認が基本です。人間がテスト中に「あれ、この動き変じゃない?」と気づくような探索的な発見は、自動テストの守備範囲外です。

加えて、テストツールの導入・習得コスト、テストスクリプトの継続的な保守工数、既存の手動テストからの移行に必要な時間も無視できません。

つまり、自動化の欠点や限界を理解したうえで、利点をうまく活かすしくみを作ることが大事です。ここからは、その具体的なしくみの話に入ります。

全体のアーキテクチャ

先に完成形を見せます。

QAメンバー / PM
    ↓ ブラウザでポータルにアクセス、テストを選択して実行
AWS App Runner(ポータルサイト / Go)
    ↓ ECS RunTaskを呼び出し
Amazon ECS(Playwright入りコンテナを起動してテスト実行)
    ↓ 結果を保存
Amazon S3(HTMLレポートを静的サイトとして公開)
    ↑ イメージを取得
Amazon ECR(コンテナイメージを保管)

ユーザーはブラウザでポータルを開き、実行したいテストを選んでボタンを押すだけです。裏でECSのコンテナが立ち上がり、テストが終わるとS3にレポートが公開され、結果を通知します。

なぜE2EテストにECSなのか

E2Eテストは単体テストと大きく違う点があります。実際のブラウザを起動してWebサイトを操作することです。

つまり実行環境にChromiumやWebKitのバイナリ、各種共有ライブラリ、フォントなど大量の依存が必要です。ローカルPCでは「npx playwright installしたら動いた」で済みますが、クラウドで同じことをやろうとすると環境構築がかなり面倒です。

ここでECSが効きます。Playwrightは公式にDockerイメージ(mcr.microsoft.com/playwright)を提供していて、ブラウザと依存ライブラリが全部入りです。これをベースにテストスクリプトを載せてECRにプッシュしておけば、ECSのRunTaskで起動するだけで、どこでも同じテスト環境が再現されます。イメージのバージョンをプロジェクト側と合わせておけば、「ローカルでは動くのにコンテナだと動かない」もなくなります。

さらに、ECSのタスクは実行が終われば自動的に破棄されます。常時稼働のサーバーを持つ必要がないので、テストを回したときだけコストが発生します。1日に数回テストを実行する程度であれば、インフラコストはかなり抑えられます。

コンテナイメージを作る

ECSで動かすコンテナイメージの構成です。テスト実行後にS3へのアップロードや通知を行うバッチ処理も同梱します。

# Stage 1: バッチ処理のビルド(Go)
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/runner ./cmd/runner

# Stage 2: Playwright環境にテストスクリプトを載せる
FROM mcr.microsoft.com/playwright:v1.50.0-noble
WORKDIR /app

# Node.js依存のインストール
COPY package.json package-lock.json ./
RUN npm ci

COPY --from=builder /out/runner ./runner
COPY playwright.config.ts ./
COPY pages/ ./pages/
COPY testCases/ ./testCases/
COPY scenarios/ ./scenarios/

ENTRYPOINT ["./runner"]

mcr.microsoft.com/playwright:v1.50.0-nobleのようにバージョンとディストリビューションを固定するのが大事です。:latestだとイメージ更新でPlaywrightのバージョンが変わり、プロジェクト側のpackage.jsonと食い違ってブラウザが見つからないエラーが起きることがあります。バージョンはプロジェクトで使っているPlaywrightに合わせてください。

また、npm ciでNode.jsの依存関係をインストールしています。これがないとnpx playwright testが依存解決できずに失敗する場合があります。

テスト結果をチームが信頼できる「書き方」をする

コンテナ化だけでは不十分です。テストそのものが不安定だと、どこで動かしてもチームはテスト結果を信頼できません。ここからはPlaywrightのテスト設計について書きます。

アクセシビリティベースで要素を特定する

E2Eテストが不安定になる定番の原因が、CSSセレクタやXPathによるHTML要素の特定です。

// こういうセレクタはHTMLの構造変更で簡単に壊れる
await page.click('#root > div > form > div:nth-child(3) > button');

HTMLのdiv構造を1つ変えただけで、このテストは壊れます。

Playwrightでは、ARIAロールやラベルといったアクセシビリティ属性で要素を特定できます。

// こちらはHTMLの構造が変わっても壊れにくい
await page.getByRole('button', { name: '送信' }).click();
await page.getByLabel('ユーザー名').fill('tanaka');
await page.getByPlaceholder('パスワードを入力').fill('secret');

ボタンのテキストやラベルの文言が同じなら、周囲のHTML構造が変わってもテストは通り続けます。

待機と再試行を自動化する

もうひとつの不安定要因は、「ページの読み込みが遅くてテストが落ちる」パターンです。sleep(3000)のような固定待機は、速いときは無駄に待ち、遅いときは足りないという最悪の解決策です。

Playwrightにはauto-waitingの仕組みがあり、操作対象の要素が操作可能になるまで自動で待ってくれます。加えて、設定ファイルでリトライ回数を指定できるので、一時的なネットワーク不調による失敗も吸収できます。

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './scenarios',
  retries: process.env.CI ? 2 : 0, // CI環境では2回リトライ、ローカルではリトライなし
  fullyParallel: true,
  workers: 4,
  reporter: [['html', { open: 'never' }]],
  use: {
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',   // 失敗時のみ動画を保持
    trace: 'on-first-retry',      // リトライ時にトレースを記録
  },
  projects: [
    { name: 'chrome', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'safari', use: { ...devices['Desktop Safari'] } },
  ],
});

projectsで複数ブラウザを指定しておけば、Chrome固有・Safari固有のバグも見逃しません。retriesprocess.env.CIで分岐させているので、ローカルでデバッグするときはリトライが走らずすぐに失敗原因を確認できます。ECS上(CI環境)では環境変数CI=trueをセットしておいてください。

テストジェネレータでテスト作成のハードルを下げる

テストスクリプトの作成を特定のエンジニアに依存させると、その人が異動・退職した時点でテストの保守が止まります。

Playwrightにはtest generator(テストジェネレータ)機能があり、ブラウザ上でWebサイトを操作するだけでテストコードが自動生成されます。コードを1行も書かずにテストの雛形を作れるので、「コードは書けないけどテスト手順は分かる」というQAメンバーでもテスト作成に参加できます。

対応言語もTypeScript、JavaScript、Python、Java、.NETと幅広いので、チームで使い慣れた言語を選べます。

テストの修正コストを下げる設計

テストが安定しても、仕様変更のたびに10ファイル修正するようでは続きません。

ページ単位でクラスを切る

Playwright公式が推奨しているPage Object Models(POM)パターンを使います。Webサイトの各ページをクラスとして切り出し、HTML要素の特定とページ操作をそこに閉じ込めます。

// pages/loginPage.ts
import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  private readonly userField: Locator;
  private readonly passField: Locator;
  private readonly loginBtn: Locator;

  constructor(private page: Page) {
    this.userField = page.getByLabel('ユーザー名');
    this.passField = page.getByLabel('パスワード');
    this.loginBtn = page.getByRole('button', { name: 'ログイン' });
  }

  async login(user: string, pass: string) {
    await this.userField.fill(user);
    await this.passField.fill(pass);
    await this.loginBtn.click();
  }
}
// pages/dashboardPage.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class DashboardPage {
  private readonly welcomeText: Locator;

  constructor(private page: Page) {
    this.welcomeText = page.getByRole('heading', { level: 1 });
  }

  async expectWelcomeMessage(name: string) {
    await expect(this.welcomeText).toContainText(name);
  }
}

テストシナリオはこれらを組み合わせるだけです。

// scenarios/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from '../pages/loginPage';
import { DashboardPage } from '../pages/dashboardPage';

test('ログイン後にダッシュボードが表示される', async ({ page }) => {
  await page.goto('https://example.com/login');

  const loginPage = new LoginPage(page);
  await loginPage.login('tanaka', 'secret');

  const dashboard = new DashboardPage(page);
  await dashboard.expectWelcomeMessage('tanaka');
});

ログインフォームの構造が変わっても、修正するのはLoginPageクラスだけです。このクラスを使っている全テストに修正が波及することはありません。

フォルダ構成を3層に分ける

POMパターンを採用する場合、テストスクリプトを3つの層に分けておくと、テストが増えても見通しがよくなります。

./
├── pages/               # ページオブジェクト層
│   ├── loginPage.ts
│   ├── dashboardPage.ts
│   ├── searchPage.ts
│   └── cartPage.ts
├── testCases/           # テストケース層
│   ├── authTestCase.ts
│   └── shoppingTestCase.ts
├── scenarios/           # テストシナリオ層
│   ├── login.spec.ts
│   └── purchase.spec.ts
└── playwright.config.ts

pagesはWebサイトの各ページに対応するクラスで、要素の特定と基本操作だけを書きます。先ほどのLoginPageがこれにあたります。

testCasesは、複数のページオブジェクトを組み合わせて「機能単位の操作」をまとめる層です。たとえば認証まわりのテストケースならこうなります。

// testCases/authTestCase.ts
import { LoginPage } from '../pages/loginPage';
import { DashboardPage } from '../pages/dashboardPage';
import { type Page } from '@playwright/test';

export class AuthTestCase {
  private loginPage: LoginPage;
  private dashboardPage: DashboardPage;

  constructor(private page: Page) {
    this.loginPage = new LoginPage(page);
    this.dashboardPage = new DashboardPage(page);
  }

  async loginAndVerifyDashboard(user: string, pass: string) {
    await this.loginPage.login(user, pass);
    await this.dashboardPage.expectWelcomeMessage(user);
  }
}

scenariosは、テストケースを組み合わせてユーザーが実際に体験する操作フローを記述する層です。.spec.ts.test.tsの拡張子を付けます。

// scenarios/purchase.spec.ts
import { test } from '@playwright/test';
import { AuthTestCase } from '../testCases/authTestCase';
import { ShoppingTestCase } from '../testCases/shoppingTestCase';

test('ログインして商品を購入する', async ({ page }) => {
  await page.goto('https://example.com/login');

  const auth = new AuthTestCase(page);
  await auth.loginAndVerifyDashboard('tanaka', 'secret');

  const shopping = new ShoppingTestCase(page);
  await shopping.searchAndAddToCart('Playwright入門');
  await shopping.checkout();
});

ページオブジェクト → テストケース → テストシナリオの順に抽象度が上がるので、テストシナリオを読むだけで「何をテストしているか」がすぐ分かります。

test.stepでレポートの可読性を上げる

テストをECS上でクラウド実行すると、テスト結果を見るのはテストを書いた本人とは限りません。QAメンバーやPMがHTMLレポートを開いて「何が起きたか」を把握する必要があります。

Playwrightのtest.stepを使うと、テストの各操作に説明文を付与できます。この説明文がHTMLレポートに表示されるので、テストコードを読まなくても操作の流れが分かります。

test('ログインして商品を購入する', async ({ page }) => {
  await test.step('ログインページに移動する', async () => {
    await page.goto('https://example.com/login');
  });

  await test.step('認証情報を入力してログインする', async () => {
    const auth = new AuthTestCase(page);
    await auth.loginAndVerifyDashboard('tanaka', 'secret');
  });

  await test.step('商品を検索してカートに追加する', async () => {
    const shopping = new ShoppingTestCase(page);
    await shopping.searchAndAddToCart('Playwright入門');
  });

  await test.step('購入を完了する', async () => {
    const shopping = new ShoppingTestCase(page);
    await shopping.checkout();
  });
});

HTMLレポートにはこの日本語の説明文がステップごとに並ぶので、テストが失敗したときに「どの操作で落ちたか」が一目で分かります。これは「届け方」の工夫として地味に効果が大きいです。

タグでテスト範囲を管理する

テストが増えてくると「全部実行すると時間がかかる」「特定の機能だけ確認したい」といった要望が出てきます。Playwrightのタグ機能で対応します。

test('商品検索が動作する', {
  tag: ['@search', '@sprint-42'],
}, async ({ page }) => {
  // ...
});

test('カートに商品を追加できる', {
  tag: ['@cart', '@sprint-42'],
}, async ({ page }) => {
  // ...
});

実行時に--grepで絞り込めます。

# sprint-42のテストだけ実行
$ npx playwright test --grep "@sprint-42"

# 検索機能のテストだけ実行
$ npx playwright test --grep "@search"

このタグが、後で説明するECSでの並列実行でも活きてきます。

ECSでの並列実行の仕組み

テスト数が多くなると、1つのコンテナで全テストを実行するのは時間がかかります。ここでECSの「タスクを好きなだけ起動できる」特性が活きます。

App RunnerからECSタスクを起動する

App Runnerで動くポータルサイト(Go実装)から、ECSのRunTask APIでコンテナを起動します。

func (s *TestRunner) Execute(ctx context.Context, tags []string) error {
    for _, tag := range tags {
        cmd := []string{"npx", "playwright", "test", "--grep", tag}
        _, err := s.ecs.RunTask(ctx, &ecs.RunTaskInput{
            TaskDefinition: aws.String(s.taskDef),
            Cluster:        aws.String(s.cluster),
            Count:          aws.Int32(1),
            Overrides: &types.TaskOverride{
                ContainerOverrides: []types.ContainerOverride{
                    {
                        Name:    aws.String("playwright"),
                        Command: cmd,
                    },
                },
            },
        })
        if err != nil {
            return fmt.Errorf("failed to run task for tag %s: %w", tag, err)
        }
    }
    return nil
}

たとえば["@search", "@cart", "@checkout"]を渡せば、3つのコンテナが同時に起動し、それぞれが担当範囲のテストだけを実行します。タグの切り方次第でテスト範囲を自由に分割・並列化できます。

なお、ECSをFargateで動かす場合はRunTask時にnetworkConfiguration(サブネット、セキュリティグループ、パブリックIPの割り当て)の指定が必須です。上のコード例では省略していますが、これがないとタスク起動時にエラーになるので注意してください。特にAssignPublicIpの設定はサブネットの種類によって変わります。パブリックサブネットで動かす場合はENABLEDにしないとECRからイメージをプルできません。プライベートサブネットの場合はDISABLEDにしたうえで、NATゲートウェイかVPCエンドポイント経由でECRにアクセスできるようにしておく必要があります。

タスク定義はコンパクトに設定します。

{
  "containerDefinitions": [
    {
      "name": "playwright",
      "cpu": 1024,
      "memoryReservation": 2048,
      "image": "123456789.dkr.ecr.ap-northeast-1.amazonaws.com/e2e-tests:latest"
    }
  ]
}

1vCPU、メモリ2GBでPlaywrightのテストは十分動きます。ハイスペックな1台で頑張るより、このサイズのコンテナを複数起動したほうがスケールしやすいです。

IAMポリシー

ECSまわりのIAMは、呼び出し側(App Runner)のロールタスク側のロールを分けて考える必要があります。

まず、App RunnerからECSタスクを起動するために必要な呼び出し側の権限です。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecs:RunTask",
        "ecs:DescribeTaskDefinition"
      ],
      "Resource": "arn:aws:ecs:<region>:<account-id>:task-definition/<task-def-name>:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecs:StopTask",
        "ecs:DescribeTasks",
        "ecs:ListTasks"
      ],
      "Resource": "arn:aws:ecs:<region>:<account-id>:cluster/<cluster-name>"
    },
    {
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": [
        "arn:aws:iam::<account-id>:role/<ecs-task-role>",
        "arn:aws:iam::<account-id>:role/<ecs-task-execution-role>"
      ]
    }
  ]
}

<region><account-id><cluster-name>などはご自身の環境に置き換えてください。

iam:PassRoleはApp RunnerがECSタスクに「このロールを使ってね」と渡す権限です。渡す対象は2つあります。

  • タスクロール(ecsTaskRole) — タスク内でS3にレポートをアップロードしたり、通知を送ったりするための権限
  • タスク実行ロール(ecsTaskExecutionRole) — ECSがECRからイメージを取得したり、CloudWatch Logsにログを書き込んだりするための権限

iam:PassRoleを忘れるとRunTaskが権限エラーになるので注意してください。

テスト結果を「誰でも見られる場所」に置く

テストが実行できても、結果が見られなければ意味がありません。

各ECSタスクはテスト完了後、PlaywrightのHTMLレポート・失敗時のスクリーンショット・操作動画をS3にアップロードします。S3の静的Webサイトホスティングを有効にしておけば、URLを開くだけでレポートが見られます。

PlaywrightのHTMLレポートには、各テストの成否、実行時間、test.stepで記述した操作手順の説明が表示されます。失敗したテストにはスクリーンショットと操作動画が添付されるので、「何をしたら落ちたのか」を非技術者でも把握できます。

なお、Playwrightのレポーターはhtml以外にもjson(JSON形式)やjunit(XML形式)での出力に対応しています。テスト結果をCI/CDのダッシュボードに連携したり、独自の集計ツールに流し込んだりしたい場合はこちらを使うことになります。今回の構成ではブラウザで直接確認できるhtmlレポーターが用途に合っています。

レポートのアクセス制御に注意

ひとつ気をつけたいのが、HTMLレポートに含まれるスクリーンショットや操作動画です。テスト対象のWebサイトによっては、画面に個人情報や機密情報が表示された状態でキャプチャされることがあります。

S3の静的ホスティングをそのまま公開するのはリスクがあるので、実運用ではCloudFront + OAC(Origin Access Control)でS3を非公開のまま配信するか、ポータルからPresigned URLを発行して期限付きで閲覧させる、といったアクセス制御を入れてください。

並列実行時のレポート集約

タグごとにECSタスクを分けて並列実行すると、レポートも複数のS3パスに分散します。「全体の結果はどうだったのか」が一目で分からない状態になりがちです。

対策としては、ポータル側でジョブIDを発行して各タスクの実行状況と結果URLを一覧で追えるようにするか、全タスク完了後に集約ページを生成してS3に置く、といった方法があります。ここは運用しながら作り込んでいく部分ですが、最初から意識しておくと後で困りません。

実際の運用フロー

最終的な運用フローはこうなります。

  1. QAメンバーがブラウザでポータルサイトを開く
  2. 実行したいテスト範囲(スプリント単位、機能単位など)を選択して実行ボタンを押す
  3. ECS上で複数コンテナが立ち上がり、テストが並列実行される
  4. 完了するとSlackなどに通知が届く
  5. S3上のHTMLレポートで結果を確認する

ポータルサイトはSwagger UIでAPIドキュメントとして公開する程度のシンプルなもので十分です。「テストを選んでExecuteを押す」だけの操作なので、Playwrightやコマンドラインの知識は一切不要です。

まとめ

E2Eテストが廃墟になる問題は、テストの「書き方」と「届け方」をセットで考える必要があります。

書き方の工夫:

  • アクセシビリティベースで要素を特定し、HTML構造の変更に強くする
  • auto-waitingとリトライで一時的な不安定さを吸収する
  • POMパターン + 3層のフォルダ構成で仕様変更時の修正箇所を1箇所に閉じ込める
  • test.stepで操作の説明を付与し、レポートの可読性を上げる

届け方の工夫:

  • Playwright公式Dockerイメージ(バージョン固定)で環境差異をなくす
  • ECSのタスクとして実行し、タグでテスト範囲を分割・並列化する
  • S3にHTMLレポートを公開し、誰でもブラウザから結果を確認できるようにする
  • レポートのアクセス制御と結果集約を忘れずに設計する

テストを書くこと自体は手段でしかなくて、それがチーム全体に届いて初めて価値が出ます。「テストの実行と結果確認のハードルを下げる」という視点を持つと、E2Eテスト自動化の生存率がだいぶ変わるんじゃないかと思います。

最後までお読みいただきありがとうございました!

参考リンク

33
株式会社エクスプラザ

Discussion

ログインするとコメントできます
33