たかぎとねこの忘備録

プログラミングに関する忘備録を自分用に残しときます。マサカリ怖い。

Oakでルート用のミドルウェアをテストしたい話

oakでルート用のミドルウェアに対するテストを行おうとした時に躓いたので、そのための忘備録。

標準のモッキングライブラリ

Oakではモック用のユーティリティ関数が用意されている。

https://deno.land/x/oak@v7.7.0/docs/testing.md

通常のミドルウェアに対するユニットテストでは、testing名前空間で用意されている関数で問題ない。

しかし、ルート用のミドルウェアをテストしたい時、内部でリクエストボディを参照したい場合にtesting.createMockContextでは、request.bodyを設定することができない。

なので意図したテストをすることが難しい。

Object literal may only specify known properties, and 'request' does not exist in type 'MockContextOptions<string, ParamsDictionary, Record<string, any>>'

Superoakを使う

SuperoakはOakフレームワークのHTTPテストを行うために必要な設定等の抽象度を高めて、よりテストしやすくするために作られたモジュール。

github.com

まず、テストしたいルート用のミドルウェアを定義する。

ミドルウェアは、router.post("/user", (ctx) => { ctx.response.body = "Post!"; });の様に、直接コールバック関数として定義しないで、個別に定義をしておく。

// routes/auth.ts
import { Middleware } from "https://deno.land/x/oak@v10.5.1/mod.ts";

export const signup: Middleware = async (ctx) => {}

Deno.testに登録したfn内で、ルートの設定とアプリの設定を行う。

DenoDBを使用している場合は、リクエストを実行する前にawait db.sync();を呼び出しておく。

import { Application, Router } from "https://deno.land/x/oak@v10.5.1/mod.ts";

Deno.test({
  name: "smoke test",
  async fn() {
    ....
    const router = new Router();
    router.post("/auth/signup", signup);

    const app = new Application();
    app.use(router.routes());
    app.use(router.allowedMethods());

    await db.sync();
    ...
  }
});

今回はform-dataでデータを送信することを想定する。

Superoakのサンプルコードでは、.set("Content-Type", "application/json")を使用してContent Typeを指定している。

しかし、Form Dataを送信したい時に、.set("Content-Type", "multipart/form-data")を指定してしまうと、 "form-data" does not contain a valid boundary.というエラーが発生してしまう。

なので、ここはあえてContent-Typeは設定しないでおく。

javascript - fetch - Missing boundary in multipart/form-data POST - Stack Overflow

formData.append("email", "メールアドレス")のように値をリクエストボディに設定したい場合は、fieldメソッドを使用する。

import { superoak } from "https://deno.land/x/superoak@4.7.0/mod.ts";

...

const request = await superoak(app);
await request
    .post("/auth/signup")
    .field("email", email)
    .field("password", password)
    .expect(200)
    .expect((response) => {
      assertExists(response.body.token);
      assertExists(response.body.id);
      assertEquals(response.body.email, email);
    });

最終的には次の様な感じ

// auth.signup.test.ts

import faker from "https://cdn.skypack.dev/@faker-js/faker@v5.5.3?dts";
import { superoak } from "https://deno.land/x/superoak@4.7.0/mod.ts";
import { Application, Router } from "https://deno.land/x/oak@v10.5.1/mod.ts";
import {
  assertExists,
  assertEquals,
} from "https://deno.land/std@0.144.0/testing/asserts.ts";
import { signup } from "/routes/auth.ts";
import db from '/db.ts'

Deno.test({
  name: "smoke test",
  async fn() {
    // emailを準備する
    const email = faker.internet.email();
    // passwordを準備する
    const password = faker.internet.password();

    // ルートを設定する
    const router = new Router();
    router.post("/auth/signup", signup);
    // Oakアプリを準備する
    const app = new Application();
    app.use(router.routes());
    app.use(router.allowedMethods());
    // DBを同期させる
    await db.sync({ drop: true });
    // リクエストを準備する
    const request = await superoak(app);
    await request
      .post("/auth/signup")
      .field("email", email)
      .field("password", password)
      .expect(200)
      .expect((response) => {
        assertExists(response.body.token);
        assertExists(response.body.id);
        assertEquals(response.body.email, email);
      });
  },
});

SuperOakのインスタンスは1つのリクエストにつき1つ用意しないといけないことに注意する。

https://github.com/cmorten/superoak#request-has-been-terminated-error