
Playwright × Cucumber (Gherkin記法)で API インテグレーションテストを書いてみた
製造ビジネステクノロジー部の小林です。
今回は 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 は日本語で『キュウリ』という意味です。ロゴも可愛らしいキュウリです。
Node.js 向けの実装が cucumber-js で、今回はこれを使います。
Gherkin とは
Gherkin は Given / When / Then で前提・操作・期待結果を書く記法で、日本語も使えるのでそのまま仕様書として読めます。
Playwright とは
Playwright はブラウザ自動化ツールで、E2E テストで有名です。
request(HTTP クライアント)と expect(アサーション)が @playwright/test パッケージに同梱されているため、API テストにも使えます。
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 をインストールするだけで request と expect は使えるので、ブラウザ 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.json の scripts を編集して、サーバー起動とテスト実行を一発で打てるようにします。
"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)

「.」 ひとつが 「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 エラーを出します。

そのため、既存の 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.js の require で指定したパス配下の step ファイルは 全部 読み込まれます。なので step ファイルを分割しても問題なく、共通ステップ(例:レスポンスのステータスコードは {int} である のような汎用的なもの)は別ファイルに切り出して使い回すのが定番のパターンです。
おわりに
今回は、Cucumber + Playwright を、一から組み立てて動かしてみました。
この記事が、「Cucumber + Playwright、名前は聞くけど触ったことない」という方の最初の一歩になれば幸いです。





