2025.05.19

vitest-unit-test

Remix × Vitest で Login API をテストする

Shuji

Shuji

  • Vitest
  • Remix
  • React

はじめに

Vitest は、Jest のような API を備えた高速なテストフレームワークであり、Remix の loaderaction のサーバーサイドのテストにも適用できます。

本記事では、Vitest を使用して login.tsxloaderaction をテストしてみました。

テスト対象の loader と action

login.tsxloaderaction は以下のような処理を行います。

  • loader
    • ユーザーがログイン済みなら / にリダイレクト
    • ログインしていなければ {} を返す (HTTP 200)
  • action
    • emailpassword のバリデーションを行う
    • ユーザー認証 (verifyLogin) を実行
    • 認証成功ならセッションを作成し、指定のページ (redirectTo) にリダイレクト
export const loader = async ({ request }: LoaderFunctionArgs) => {
 const userId = await getUserId(request);
 if (userId) return redirect("/");
 return json({});
};

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const email = formData.get("email");
  const password = formData.get("password");
  const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
  const remember = formData.get("remember");

  if (!validateEmail(email)) {
    return json(
      { errors: { email: "Email is invalid", password: null } },
      { status: 400 },
    );
  }

  if (typeof password !== "string" || password.length === 0) {
    return json(
      { errors: { email: null, password: "Password is required" } },
      { status: 400 },
    );
  }

  if (password.length < 8) {
    return json(
      { errors: { email: null, password: "Password is too short" } },
      { status: 400 },
    );
  }

  const user = await verifyLogin(email, password);

  if (!user) {
    return json(
      { errors: { email: "Invalid email or password", password: null } },
      { status: 400 },
    );
  }

  return createUserSession({
    redirectTo,
    remember: remember === "on" ? true : false,
    request,
    userId: user.id,
  });
};

必要なモック

セッション管理のモック

export const mockSession = {
  getUserId: vi.fn<() => Promise<`email#${string}` | undefined>>(),
  createUserSession: vi.fn<(
    args: { redirectTo: string; remember: boolean; request: Request; userId: string }
  ) => Promise<Response>>(),
};

ユーザー認証のモック

export const mockUser = {
  verifyLogin:
    vi.fn<
      (
        email: string,
        password: string,
      ) => Promise<{ id: email#${string} } | null>
    >(),
};

loader のテスト

  • ログイン済みの場合 / にリダイレクトすることを確認
  • ログインしていない場合 HTTP 200 で {} を返すことを確認
import { redirect } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { describe, it, expect, vi } from "vitest";
import { mockSession } from "~mocks/auth/session.mock";

vi.mock("~/session.server", () => mockSession);

import { loader } from "~/routes/_auth+/login";
import { getUserId } from "~/session.server";

describe("loader", () => {
  it("redirects to '/' if user is logged in", async () => {
    vi.mocked(getUserId).mockResolvedValue("email#user-id");
    const request = new Request("http://localhost/");
    const args: LoaderFunctionArgs = { request, params: {}, context: {} };
    const response = await loader(args);
    expect(response).toEqual(redirect("/"));
  });

  it("returns an empty JSON response if user is not logged in", async () => {
    vi.mocked(getUserId).mockResolvedValue(undefined);
    const request = new Request("http://localhost/");
    const args: LoaderFunctionArgs = { request, params: {}, context: {} };
    const response = await loader(args);
    expect(response.status).toBe(200);
    const responseBody = await response.json();
    expect(responseBody).toEqual({});
  });
});

action のテスト

  • 無効なメールアドレス (invalid-email) の場合
    • 400 Bad Request を返し、適切なエラーメッセージを含むことを確認
  • 正しいログイン情報 (user@example.com) の場合
    • verifyLogin が成功し、createUserSession が適切な引数で呼び出され、リダイレクトされることを確認
import { redirect } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
import { describe, it, expect, vi } from "vitest";
import { mockSession } from "~mocks/auth/session.mock";
import { mockUser } from "~mocks/auth/user.mock";

vi.mock("~/models/user.server", () => mockUser);
vi.mock("~/session.server", () => mockSession);

import { verifyLogin } from "~/models/user.server";
import { action } from "~/routes/_auth+/login";
import { createUserSession } from "~/session.server";

describe("action", () => {
  it("returns an error if email is invalid", async () => {
    const formData = new FormData();
    formData.append("email", "invalid-email");
    formData.append("password", "password123");

    const request = new Request("http://localhost/", {
      method: "POST",
      body: formData,
    });

    const args: ActionFunctionArgs = { request, params: {}, context: {} };
    const response = await action(args);
    const jsonResponse = await response.json();
    expect(jsonResponse).toEqual({
      errors: { email: "Email is invalid", password: null },
    });
    expect(response.status).toBe(400);
  });

  it("creates a user session on successful login", async () => {
    vi.mocked(verifyLogin).mockResolvedValue({
      id: "email#user-id",
      email: "user@example.com",
    });
    vi.mocked(createUserSession).mockResolvedValue(redirect("/dashboard"));

    const formData = new FormData();
    formData.append("email", "user@example.com");
    formData.append("password", "password123");
    formData.append("redirectTo", "/dashboard");
  
    const request = new Request("http://localhost/", {
      method: "POST",
      body: formData,
    });
    const args: ActionFunctionArgs = { request, params: {}, context: {} };
    const response = await action(args);

    expect(response).toEqual(redirect("/dashboard"));
    expect(createUserSession).toHaveBeenCalledWith({
      redirectTo: "/dashboard",
      remember: false,
      request,
      userId: "email#user-id",
    });
  });
});

そして、テストを実行してみます。

$ npx vitest run test/auth/login.action.test.ts

 RUN  v2.1.9

 ✓ test/auth/login.action.test.ts (2)
   ✓ action (2)
     ✓ returns an error if email is invalid
     ✓ creates a user session on successful login

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  08:34:48
   Duration  565ms (transform 90ms, setup 122ms, collect 104ms, tests 7ms, environment 136ms, prepare 39ms)

まとめ

Vitest のモック機能 (vi.mock) を活用することで、データベースや API に依存せずに 効率的にテストできる ことができました。

今後は スナップショットテストカバレッジ測定 などの機能や、MSW (Mock Service Worker) と組み合わせるのも良いですね!