Playwright × Cucumber (Gherkin記法)で API インテグレーションテストを書いてみた

Playwright × Cucumber (Gherkin記法)で API インテグレーションテストを書いてみた

2026.06.14

製造ビジネステクノロジー部の小林です。

今回は Cucumber + Playwright を使った API のインテグレーションテストを試してみました。

はじめは「Gherkin 記法って何?」「Playwright ってブラウザの E2E テスト用のツールじゃないの?」という状態からのスタートでした。しかし実際に触ってみると、テスト仕様書とテストコードが一体化するという独特の世界観が非常に面白いかったです。

今回は、ローカル環境で Gherkin で書いたシナリオを使って API をテストするところまで動かしてみました。その過程で得られた気づきや、つまずいたポイントを共有します。

ゴール

以下のようなプロジェクトを組み立てて動かします。

playwright-cucumber-demo/
├── package.json
├── tsconfig.json
├── cucumber.js              # Cucumber 設定
├── src/
│   └── server.ts            # API(Express)
├── features/
│   └── hello.feature        # Gherkin シナリオ
└── steps/
    └── hello.steps.ts       # ステップ定義

最終的に npm test で以下の出力が出れば成功です。

........

2 scenarios (2 passed)
8 steps (8 passed)

動作環境

今回試した環境は以下のとおりです。

ツール バージョン
Node.js 24
TypeScript 6.0.3
@cucumber/cucumber 13.0.0
@playwright/test 1.60.0
express 5.2.1

そもそも何の組み合わせなのか

手を動かす前に、ざっくり「何の組み合わせか」を整理してみます。

Cucumber とは

Cucumber は BDD(振る舞い駆動開発)のテストフレームワークで、自然言語で書いたテストシナリオ(Gherkin)を実行する仕組みを提供してくれます。ちなみに Cucumber は日本語で『キュウリ』という意味です。ロゴも可愛らしいキュウリです。

https://cucumber.io/

Node.js 向けの実装が cucumber-js で、今回はこれを使います。

https://github.com/cucumber/cucumber-js

Gherkin とは

GherkinGiven / When / Then で前提・操作・期待結果を書く記法で、日本語も使えるのでそのまま仕様書として読めます。

Playwright とは

Playwright はブラウザ自動化ツールで、E2E テストで有名です。

https://playwright.dev/

request(HTTP クライアント)と expect(アサーション)が @playwright/test パッケージに同梱されているため、API テストにも使えます。

https://playwright.dev/docs/api-testing

axios + Jest を別々に入れる必要がなく 1 パッケージで完結するので、今回はそのまま採用しました。

今回は、Cucumber でテストを実行し、各ステップの中で Playwright の request で実際の API を叩いて expect で検証する、という構成です。

やってみる

実際に使ってみました。

プロジェクトを初期化する

mkdir playwright-cucumber-demo
cd playwright-cucumber-demo
pnpm init

必要なパッケージをインストールする

実行用とテスト用、分けて入れていきます。

# API サーバー用
pnpm install express

# テスト用(dev dependencies)
pnpm install --save-dev @playwright/test @cucumber/cucumber typescript ts-node @types/node @types/express

今回、Playwright のブラウザバイナリは入れません。@playwright/test をインストールするだけで requestexpect は使えるので、ブラウザ E2E に使うときの npx playwright install は不要です。

TypeScript の設定をする

tsconfig.json をプロジェクトルートに作成します。

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*", "steps/**/*"]
}

サンプル API を作る

テスト対象になる、シンプルな Express API を src/server.ts に作ります。

import express, { Request, Response } from "express";

const app = express();
app.use(express.json());

// GET /hello?name=xxx → { message: "Hello, xxx!" }
app.get("/hello", (req: Request, res: Response) => {
  const name = req.query.name as string | undefined;

  const greetTo = name ?? "world";
  res.status(200).json({ message: `Hello, ${greetTo}!` });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`API server running on http://localhost:${PORT}`);
});

クエリパラメータで name を受け取って Hello, xxx! を返すだけの API です。name が省略されたら world を返します。

ここで一度、サーバー単体で動くか確認しておきます。

pnpx ts-node src/server.ts

正常に起動すると下記のように表示されます。

playwright-cucumber-demo %pnpx ts-node src/server.ts
Packages: +20
++++++++++++++++++++
Progress: resolved 20, reused 20, downloaded 0, added 20, done
API server running on http://localhost:3000

別ターミナルから上記 API を叩いてみます。

$ curl "http://localhost:3000/hello?name=Kobayashi"
{"message":"Hello, Kobayashi!"}
$ curl "http://localhost:3000/hello"
{"message":"Hello, world!"}

期待どおり動いていますね。これからこの API を Gherkin でテストします。

Gherkin で feature ファイルを書く

features/hello.feature を作って、テストシナリオを日本語で書いていきます。

Feature: Hello API

  Scenario: nameを指定すると指定した名前で挨拶される
    Given Hello APIが起動している
    When  Hello APIを "Shoma" で呼び出す
    Then  レスポンスのステータスコードは 200 である
    And   レスポンスボディのmessageは "Hello, Shoma!" である

  Scenario: nameを省略するとworldで挨拶される
    Given Hello APIが起動している
    When  Hello APIをnameなしで呼び出す
    Then  レスポンスのステータスコードは 200 である
    And   レスポンスボディのmessageは "Hello, world!" である

これは テストコードであると同時にテスト仕様書です。

Given(前提)、When(操作)、Then(期待結果)の 3 段で書かれていて何をテストしているか分かります。
ただしこのままだとただの日本語テキストです。これを実際の処理に紐づけるのが次の step ファイルです。

step ファイルを書く

steps/hello.steps.ts を作って、feature ファイルの各行に対応する処理を書きます。

import { Given, When, Then, setDefaultTimeout } from "@cucumber/cucumber";
import {
  request,
  APIRequestContext,
  APIResponse,
  expect,
} from "@playwright/test";

setDefaultTimeout(10 * 1000);

let apiContext: APIRequestContext;
let response: APIResponse;
let responseBody: { message: string };

Given("Hello APIが起動している", async () => {
  apiContext = await request.newContext({
    baseURL: "http://localhost:3000",
  });
});

When("Hello APIを {string} で呼び出す", async (name: string) => {
  response = await apiContext.get(`/hello?name=${name}`);
  responseBody = await response.json();
});

When("Hello APIをnameなしで呼び出す", async () => {
  response = await apiContext.get("/hello");
  responseBody = await response.json();
});

Then("レスポンスのステータスコードは {int} である", async (status: number) => {
  expect(response.status()).toBe(status);
});

Then(
  "レスポンスボディのmessageは {string} である",
  async (expected: string) => {
    expect(responseBody.message).toBe(expected);
  }
);

ここが Gherkin のおもしろい部分です。

たとえば feature ファイルにこう書くと

When  Hello APIを "Kobayashi" で呼び出す

step ファイルでこの登録にマッチします。

When("Hello APIを {string} で呼び出す", async (name: string) => { ... });

{string} がプレースホルダーになっていて、feature 側の "..." で囲んだ部分がそのまま関数の引数 name として渡されます。{int} を使えば数値も同様に渡せます(ステータスコードのところで使っていますね)。

つまり Gherkin は、日本語テキストをキーとして関数を呼び出す仕組みです。

Cucumber の設定をする

プロジェクトルートに cucumber.js を作って、feature と step の場所を教えてあげます。

module.exports = {
  default: {
    requireModule: ["ts-node/register"],
    require: ["steps/**/*.ts"],
    paths: ["features/**/*.feature"],
    format: ["progress"],
  },
};
  • requireModule で ts-node を有効化(TypeScript を直接実行できるように)
  • require で step ファイルの場所を指定
  • paths で feature ファイルの場所を指定
  • format: ["progress"] で実行中の進捗を . で表示する

pnpm scripts を追加する

package.jsonscripts を編集して、サーバー起動とテスト実行を一発で打てるようにします。

"scripts": {
  "start": "ts-node src/server.ts",
  "test": "cucumber-js"
}

実行してみる

まず別ターミナルでサーバーを起動。

pnpm start
> ts-node src/server.ts

API server running on http://localhost:3000

そしてもう片方のターミナルでテストを実行します。

pnpm test

実際に出力された結果はこちら。

> playwright-cucumber-demo@1.0.0 test
> cucumber-js

........

2 scenarios (2 passed)
8 steps (8 passed)
0m 0.189s (0m 0.135s executing your code)

スクリーンショット 2026-06-14 7.48.58

「.」 ひとつが 「1 ステップ成功」 を意味します。cucumber.js で format: ["progress"] を指定しているので、こういうコンパクトな出力になっています。

今回 . が 8 個並んでいるのは、シナリオ 2 つ × Given / When / Then / And の 4 ステップ = 2 × 4 = 8 ステップ だからです。

異常系シナリオを追加してみる

API 側にバリデーションを追加してみます。name が空文字なら 400 エラーを返すロジックを src/server.ts に追加します。

// GET /hello?name=xxx → { message: "Hello, xxx!" }
app.get("/hello", (req: Request, res: Response) => {
  const name = req.query.name as string | undefined;

  if (name === "") {
    return res.status(400).json({ error: "name がありません" });
  }

  const greetTo = name ?? "world";
  res.status(200).json({ message: `Hello, ${greetTo}!` });
});

そして features/hello.feature にシナリオを追加します。

Scenario: nameが空文字だと400エラーになる
  Given Hello APIが起動している
  When  Hello APIを "" で呼び出す
  Then  レスポンスのステータスコードは 400 である
  And   レスポンスボディのerrorは "name がありません" である

ポイント 1:ステータスコードの step は再利用できる

ステータスコードの step は {int} プレースホルダーで書かれているため、200 でも 400 でも同じ定義をそのまま使えます。これが Gherkin の強みです。

typescriptThen(
  "レスポンスのステータスコードは {int} である",
  async (status: number) => {
    expect(response.status()).toBe(status);
  }
);

ポイント 2:同じテキストの step 定義を重複させない

異常系用の When を別に追加すると、Cucumber が Multiple step definitions match エラーを出します。

スクリーンショット 2026-06-14 10.02.20

そのため、既存の When を置き換えて成功・失敗の両方に対応させ、新しい Then を追加します。

When("Hello APIをnameなしで呼び出す", async () => {
  response = await apiContext.get("/hello");
  const body = await response.json();
  if (response.ok()) {
    responseBody = body;
  } else {
    errorBody = body;
  }
});

Then("レスポンスボディのerrorは {string} である", async (expected: string) => {
  expect(errorBody.error).toBe(expected);
});

正常系も異常系も同じ feature ファイルにまとめて書けるので、テスト仕様書として一目で分かりやすくなります。

気づき

テキストが 1 文字でも違うとマッチしない

feature ファイルのテキストと、step ファイルに登録したテキストは 完全一致 でマッチングされます。全角スペース、改行、句読点のあるなしで簡単にマッチしなくなるので、コピペで揃えるのが安全です。

Cucumber は require で指定されたパス配下を全部読み込む

cucumber.jsrequire で指定したパス配下の step ファイルは 全部 読み込まれます。なので step ファイルを分割しても問題なく、共通ステップ(例:レスポンスのステータスコードは {int} である のような汎用的なもの)は別ファイルに切り出して使い回すのが定番のパターンです。

おわりに

今回は、Cucumber + Playwright を、一から組み立てて動かしてみました。
この記事が、「Cucumber + Playwright、名前は聞くけど触ったことない」という方の最初の一歩になれば幸いです。

参考資料

この記事をシェアする

関連記事