entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

GitLabとOrvalを活用したフロントエンドテスト

こんにちは、pixivcobanのエンジニアをしているnyamadanです。

PIXIV DEV MEETUP 2024ではpixivcobanのフロントエンドのカバレッジをいかに向上させたかについてお伝えしました。 speakerdeck.com

pixivcobanブースにも常駐していて、小判型のティッシュペーパーを配るなどしていました。

pixivcobanとは

pixivcobanはプリペイド型の支払い手段です。定期的にキャンペーンを行っており、BOOTHpixivFANBOXをオトクにご利用いただけます。

テストを重視するモチベーション

私自身が感じていたことですが、pixivcobanは決済を取り扱うサービスである以上リリース作業にはある種の怖さがありました。

この怖さから技術選定においても消極的な判断をしてしまわないかという懸念があり、pixivcobanの積極的な開発を行うためにも自動テストが重要であると考えていました。

Orvalの活用

pixivcobanはCloud Run上のNext.jsでホストされており、バックエンドのAPIサーバーと直接HTTP APIで通信する形で実装されています。自動テストはJest + Testing Libraryで行っていました。 HTTP APIはOpenAPIフォーマットでスキーマ駆動開発を行っており、バックエンドAPIのリクエストのバリデーションやAPIドキュメントの生成等に活用しています。 テストにおいて課題と考えていたのはHTTP APIをどうモックするかということでした。 HTTP APIのモックにはMock Service Worker(MSW)を使用できますが、レスポンスのスキーマをバックエンドAPIと合わせるのは煩わしい作業でした。 我々はOpenAPIからコード生成を行うのに合わせモックレスポンスコードを生成できるOrvalを使用することとしました。

たとえば次のようなOpenAPIのYAMLがあるとします。

paths:
  /user:
    get:
      operationId: user.get
      summary: User情報を取得
      responses:
        "200":          content:
            application/json:
              schema:
                $ref: "#/components/schemas/user_response"
components:
  schemas:
    user_response:
      description: ユーザー情報
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        age:
          type: integer
        created_at:
          type: string
          format: date
      required:
        - id
        - name
        - age
        - created_at

生成されたAPIは次のようになります。

/**
 * ユーザー情報
 */
export interface UserResponse {
  age: number;
  created_at: string;
  id: string;
  name: string;
}

/**
 * @summary User情報を取得
 */
export type userGetResponse = {
  data: UserResponse;
  status: number;
};

export const getUserGetUrl = () => {
  return `/user`;
};

export const userGet = async (
  options?: RequestInit,
): Promise<userGetResponse> => {
  const res = await fetch(getUserGetUrl(), {
    ...options,
    method: "GET",
  });
  const data = await res.json();

  return { status: res.status, data };
};

Orvalから生成されたモックコードは次のようにfakerを使用したモックレスポンスコードが自動的に生成されています。

export const getUserGetResponseMock = (
  overrideResponse: Partial<UserResponse> = {},
): UserResponse => ({
  age: (() => faker.number.int({ min: 18, max: 150 }))(),
  created_at: faker.date.past().toISOString().split("T")[0],
  id: faker.string.uuid(),
  name: (() => faker.person.fullName())(),
  ...overrideResponse,
});

export const getFizzbuzzPostResponseMock = (
  overrideResponse: Partial<FizzbuzzResponse> = {},
): FizzbuzzResponse => ({ data: faker.word.sample(), ...overrideResponse });

export const getUserGetMockHandler = (
  overrideResponse?:
    | UserResponse
    | ((
        info: Parameters<Parameters<typeof http.get>[1]>[0],
      ) => Promise<UserResponse> | UserResponse),
) => {
  return http.get("*/user", async (info) => {
    await delay(1000);

    return new HttpResponse(
      JSON.stringify(
        overrideResponse !== undefined
          ? typeof overrideResponse === "function"
            ? await overrideResponse(info)
            : overrideResponse
          : getUserGetResponseMock(),
      ),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  });
};

モック部分はfakerを使用して出力されます。そのため、実行ごとに結果が異なることがあるため、そこはseedを固定してテストすることとしています。

import { faker } from '@faker-js/faker';
import { setupServer } from 'msw/node';
import { userGet } from './generated/api';
import { getSandboxMock, getUserGetResponseMock } from './generated/api.msw';
import { getUserNameWithAge } from './user';

export const server = setupServer(...getSandboxMock())

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe("User", () => {
  beforeEach(() => {
    faker.seed(1); // seedを固定する
  });

  test("name with age", async () => {
    // userGetのモックレスポンスを受け取る
    const user = await userGet();
    expect(getUserNameWithAge({ ...user, age: 28, "name": "nyamadan" }))
      .toBe("nyamadan (28)")

    // あるいはモック生成関数を直接利用する
    // ageやname以外のプロパティはランダムに生成させる
    expect(getUserNameWithAge(getUserGetResponseMock({ age: 28, "name": "nyamadan" })))
      .toBe("nyamadan (28)")
  })
})

GitLabの活用

フロントエンドの自動テストはGitLab CI上でテストされ、テストに成功したコードのみデプロイ可能となります。

GitLabではcoberturaフォーマットで出力したファイルをArtifactsにアップロードすることで、カバレッジを可視化できます。

次のようにjest設定でcoverageReportersを設定すればcobertura形式でエクスポートできます。

coverageReporters: ["cobertura"]

カバレッジを可視化すればMerge Requestのコード上でテストカバレッジが存在しない箇所を明らかにできます。

これにより、実装者がどの部分をテストしていないか一目でわかるとともに、レビュワーからも必要なテストが実施されているか、テストケースとカバレッジに齟齬がないかも分かるようになりました。

コードカバレッジの変化

コードカバレッジの範囲は見直しをしていていくらか増減はしているのですが、4月から8月までの一貫したルールにおいてコードカバレッジは上昇を続けています。

また、当初の目的であった積極的な開発もコードカバレッジの上昇に合わせて上昇しています。

最後に

この記事ではpixivcobanのフロントエンドテストについての取り組みを紹介させていただきました。

品質を妥協せずスピード感のある開発のために今後もテストを重視した開発を続けていきます。

pixivcobanでは品質を妥協せずスピード感ある開発を行いたいバックエンドエンジニアを募集しています。

hrmos.co

nyamadan
2019年中途入社。好きなメソッドはflatMap。