STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Playwrightを活用したRESTful APIのシナリオテスト

はじめに

こんにちは、STORESの高田です

今回は Playwright を用いて RESTful API のシナリオテストを実装したことについてご紹介します

Playwright ではAPIの操作フローをプログラムで書けるため、想定利用ケースをコードで明示できる上、融通の効くテストが書けます。「初期ユーザを準備する」といった何度も行う処理を再利用可能な状態で切り出せたり、ヘッダー・認証・ミドルウェア等の処理を通したテストができるため、より実態に近い状態でテストできるメリットがあります

ただ、ここで直面する問題として、公開されたAPIだけではシナリオが完結できないケースが出てきます。例えば、サインアップ処理時に必要なトークンをデータベースから取得せざるを得ないケースなど、シナリオテストを実装するにあたって、どうしてもデータベースの中身を見たくなるケースが出てきます

それに対する一つの解決策として、データベースとシナリオテストの間に proxy を実装してデータを操作できるようにしています。その具体例についてご紹介します

シナリオテストからデータベースを操作する

シナリオテストを実装するにあたって、どうしてもデータベースの中身を見たくなるケースでは、データベースとシナリオテストの間に proxy を実装しておくと自由にデータを参照でき、テストの自由度が上がります。データの取得(SELECT文)だけでなく、削除(DELETE文)なども実行できるとより利便性が上がります

データの取得

都合により疑似コードとなりますが、まずは使用例から説明します。利用側は以下のように、SQLを利用してデータを取得します

test.describe("/user", () => {
  let ctx: APIRequestContext;
  let pgAPICtx: APIRequestContext;

  test.beforeAll(async ({ playwright }) => {
    ctx = await request.newContext({
      baseURL: "http://api:8080",
    });
    pgAPICtx = await request.newContext({
      baseURL: "http://pg-api:3020",
    });
  });

  test("signup and reset password", async ({ page }) => {
    // signup 処理がここにある

    const fooTokenRes = await pgAPICtx.post(`/select`, {
      data: {
        query: `SELECT * FROM foo_tokens WHERE id = $1`,
        bind: [id],
      },
    });
    expect(fooTokenRes.ok()).toBeTruthy();

    const fooTokenResBody = await fooTokenRes.json();
    

取得したデータは fooTokenResBody[0].token のように、後続のテストで使えるようになります

データの削除

DELETE 文も発行でき、データの初期化も次のように可能になっています

export async function clearFooLogs() {
  const pgAPICtx = await newPgAPICtx();
  const fooLogRes = await pgAPICtx.post(`/exec`, {
    data: {
      query: `DELETE FROM foo_logs`,
      bind: [],
    },
  });
  expect(fooLogRes.ok()).toBeTruthy();
}

proxy の実装

proxy の実装は Go で行っており、以下のような proxy を開発用の docker compose services に追加することで、シナリオテストから JSON を POST することでデータベース操作が可能になります

プログラムとしては短いですし、動けば良い類のアプリケーションなのであまり凝ったことはせずに済ませてしまっています

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/jmoiron/sqlx"

    _ "github.com/lib/pq" // PostgreSQL Driver
)

var DB *sqlx.DB

func init() {
    dsn := fmt.Sprintf(
        "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Tokyo",
        // fill args here
    )
    db, err := sqlx.Open("postgres", dsn)
    if err != nil {
        log.Fatal(err)
    }
    DB = db
}

func main() {
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Post("/select", handleSelect)
    r.Post("/exec", handleExec)
    log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", 3020), r))
}

func handleSelect(w http.ResponseWriter, r *http.Request) {
    payload := struct {
        Query string `json:"query"`
        Bind  []any  `json:"bind"`
    }{}
    if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
        log.Println(err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    log.Printf("query: %s\tbind: %s\n", payload.Query, payload.Bind)

    rows, err := DB.Queryx(payload.Query, payload.Bind...)
    if err != nil {
        log.Println(err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    results := []map[string]any{}
    for rows.Next() {
        result := make(map[string]any)

        if err := rows.MapScan(result); err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }
        results = append(results, result)
    }

    if err := json.NewEncoder(w).Encode(results); err != nil {
        log.Println(err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    return
}

func handleExec(w http.ResponseWriter, r *http.Request) {
    payload := struct {
        Query string `json:"query"`
        Bind  []any  `json:"bind"`
    }{}
    if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
        log.Println(err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    log.Printf("query: %s\tbind: %s\n", payload.Query, payload.Bind)

    _, err := DB.Exec(payload.Query, payload.Bind...)
    if err != nil {
        log.Println(err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    return
}

事例として参考にしてもらえればと思います