Web APIが作成されたらVitestとAjvで必ずテストを書くべき

VitestとAjvで必ずテストを書くべき

Webアプリケーションのフロントエンド開発をしていると、必ずバックエンド側で作成されたWeb APIとやり取りする場面が出てきます。

Web APIがバックエンド側で作成されているのでバックエンド側でテストをするべきですが、バックエンドエンジニアの能力が低い企業だとWeb APIのテストがしっかりとされていないことがあります。

また、APIが正しく動作することを前提にフロントエンドの開発を進めていると、ある日突然APIのレスポンスが変更されてしまい、フロント側でバグが発生することもたまにあります。

そんな問題を防ぐために、APIが作られたら必ずフロントエンドエンジニアもテストを書くべきです。その際、私がオススメするのが「Vitest + Ajv」の組み合わせです。

Vitestとは

VitestはJestと似た書き方でテストを実行できる軽量テストフレームワークです。

最近はJestよりもVitestのほうが使用されることが多く、以下のようなコードで簡単にAPIのテストができます。

products.spec.ts
import { describe, it, expect } from 'vitest'
import axios from 'axios'

describe('Products API', () => {
  it('productsのid, titleのテスト', async () => {
    const res = await axios.get('https://dummyjson.com/products/1?&select=id,title')
    expect(res.status).toBe(200)
    expect(res.data).toHaveProperty('id')
    expect(res.data).toHaveProperty('title')
  })
  
  it('存在しないidで404になることを確認', async () => {
    try {
      await axios.get('https://dummyjson.com/products/999')
    } catch (err: any) {
      expect(err.response.status).toBe(404)
    }
  })
})

Ajvとは

Ajv (Another JSON Validator)はJavaScriptで使われるJSON Schemaバリデーション用のライブラリです。

実際のWeb APIのテストはプロパティが存在するかだけでなく、型が正しいかや不要なプロパティが含まれていないかなどのテストもする必要があります。

Vitestだけで存在、型、不要なプロパティの有無をテストする場合は以下のようなコードになり、少しわかりにくくなります。

products.spec.ts
import { describe, it, expect } from 'vitest'
import axios from 'axios'

describe('Products API', () => {
  it('productsのid, titleのテスト', async () => {
    const res = await axios.get('https://dummyjson.com/products/1?&select=id,title')
    expect(res.status).toBe(200)
    const data = res.data

    // 許可されているプロパティ
    const allowedProps = ['id', 'title']

    // 必須プロパティチェック
    expect('id' in data).toBe(true)
    expect('title' in data).toBe(true)

    // 型チェック
    expect(typeof data.id).toBe('number')
    expect(typeof data.title).toBe('string')

    // 追加プロパティがないことをチェック
    const extraProps = Object.keys(data).filter((k) => !allowedProps.includes(k))
    expect(extraProps.length).toBe(0)
  })
})

Ajv (Another JSON Validator)を使用すれば以下のようにスキーマを定義してテストできるので、テストのコードがわかりやすくなります。

products.spec.ts
import { describe, it, expect } from 'vitest'
import axios from 'axios'
import Ajv from 'ajv'

const ajv = new Ajv()
const productSchema = {
  type: 'object',
  required: ['id', 'title'],
  properties: {
    id: { type: 'number' },
    title: { type: 'string' }
  },
  additionalProperties: false
}

describe('Products API', () => {
  it('productsのid, titleのテスト', async () => {
    const res = await axios.get('https://dummyjson.com/products/1?&select=id,title')
    const validate = ajv.compile(productSchema)
    const valid = validate(res.data)
    if (!valid) console.log(validate.errors)
    expect(valid).toBe(true)
  })
})

型が正しくない場合は以下のようなメッセージで教えてくれます。

※ titleの型にnumberを指定してテストした結果

Ajv エラーメッセージ

試しに以下のコマンドでvitest, axios, ajvをインストールして、products.spec.tsにコードを追加して、どのようなテスト結果になるか試してみてください。

mkdir my-test
cd my-test
touch products.spec.ts
npm init -y
npm i vitest axios ajv

テストは「vitest products.spec.ts」で実行できます。

vitest products.spec.ts

boolean型は必ず型をテストする

boolean型はpropertiesに type: 'boolean' を設定すればテストできます。

仮にfalseが文字列で返ってきた場合、JavaScriptのifの条件分岐の判定ではtrueになってしまうため、boolean型は必ず型をテストしてください。

products.spec.ts
import { describe, it, expect } from 'vitest'
import axios from 'axios'
import Ajv from 'ajv'

const ajv = new Ajv()
const flagsSchema = {
  type: 'object',
  required: ['flagA', 'flagB'],
  properties: {
    flagA: { type: 'boolean' },
    flagB: { type: 'boolean' }
  },
  additionalProperties: false
}

describe('Flags API', () => {
  it('flagA と flagB が boolean で存在するか', async () => {
    const res = await axios.get('https://dummyjson.com/c/52aa-11e1-47e0-a075')
    expect(res.status).toBe(200)
    const validate = ajv.compile(flagsSchema)
    const valid = validate(res.data)
    if (!valid) console.log(validate.errors)
    expect(valid).toBe(true)
  })
})

Web APIが単一ではなく一覧(配列)の場合

Web APIは単一ではなく一覧(配列)の場合もあるので、そのときはtypeをarrayにして以下のようになります。

products.spec.ts
import { describe, it, expect } from 'vitest'
import axios from 'axios'
import Ajv from 'ajv'

const ajv = new Ajv()
const productsSchema = {
  type: 'array',
  items: {
    type: 'object',
    required: ['id', 'title'],
    properties: {
      id: { type: 'number' },
      title: { type: 'string' }
    },
    additionalProperties: false
  }
}

describe('Products API', () => {
  it('products一覧のid, titleのテスト', async () => {
    const res = await axios.get('https://dummyjson.com/products?&select=id,title')
    expect(res.status).toBe(200)
    expect(Array.isArray(res.data.products)).toBe(true)
    const validate = ajv.compile(productsSchema)
    const valid = validate(res.data.products)
    if (!valid) console.log(validate.errors)
    expect(valid).toBe(true)
  })
})

文字列の場合は文字数の下限と上限もテスト

文字列の場合は型だけでなく文字数の下限と上限があればテストします。

例えばtitleが2文字以上50文字以下であればpropertiesのtitleにminLengthとmaxLengthを追加して以下のようになります。

products.spec.ts
const productsSchema = {
  type: 'array',
  items: {
    type: 'object',
    required: ['id', 'title'],
    properties: {
      id: { type: 'number' },
      title: {
        type: 'string',
        minLength: 2,
        maxLength: 50
      },
    },
    additionalProperties: false
  }
}

数値の場合は下限と上限もテスト

数値の場合も下限と上限があればテストします。

products.spec.ts
import { describe, it, expect } from 'vitest'
import axios from 'axios'
import Ajv from 'ajv'

const ajv = new Ajv()
const productsSchema = {
  type: 'array',
  items: {
    type: 'object',
    required: ['id', 'title', 'price'],
    properties: {
      id: { type: 'number' },
      title: { type: 'string', minLength: 2, maxLength: 50 },
      price: { type: 'number', minimum: 0.99, maximum: 2499.99 }
    },
    additionalProperties: false
  }
}

describe('Products API', () => {
  it('id, title, price の制約を満たすか', async () => {
    const res = await axios.get(
      'https://dummyjson.com/products?&select=id,title,price'
    )
    expect(res.status).toBe(200)
    expect(Array.isArray(res.data.products)).toBe(true)

    const validate = ajv.compile(productsSchema)
    const valid = validate(res.data.products)
    if (!valid) {
      console.log(validate.errors)
    }
    expect(valid).toBe(true)
  })
})

特に価格の数値の場合、0や100000などの誤った数値になると大きな問題になってしまうため、必ず下限と上限のテストが必要です。

idは連番になっているかテスト

idが連番の場合はちゃんと連番になっているかのテストも追記すると良いです。

idが連番であるテストが問題なければ、idの重複がないことも保証されます。

products.spec.ts
it('products一覧のidが連番か', async () => {
  const res = await axios.get('https://dummyjson.com/products?&select=id,title')
  const products = res.data.products
  for (let i = 1; i < products.length; i++) {
    expect(products[i].id).toBe(products[i - 1].id + 1)
  }
})

仮にidが連番ではなくUUID v7の場合は時刻を抽出して昇順かテストします。

products.spec.ts
import { describe, it, expect } from 'vitest'
import axios from 'axios'
import Ajv from 'ajv'

const ajv = new Ajv()
const productsSchema = {
  type: 'array',
  items: {
    type: 'object',
    required: ['id', 'title'],
    properties: {
      id: { type: 'string' },
      title: { type: 'string' }
    },
    additionalProperties: false
  }
}

function extractUnixTimeFromUUIDv7(uuid: string): number {
  const hex = uuid.replace(/-/g, '')
  const timestampHex = hex.substring(0, 12)
  return parseInt(timestampHex, 16)
}

describe('Products API', () => {
  it('products一覧のidがUUID v7の時系列順になっているか', async () => {
    const res = await axios.get('https://dummyjson.com/products?&select=id,title')
    expect(res.status).toBe(200)
    expect(Array.isArray(res.data.products)).toBe(true)
    const validate = ajv.compile(productsSchema)
    const valid = validate(res.data.products)
    if (!valid) console.log(validate.errors)
    expect(valid).toBe(true)
    const timestamps = res.data.products.map((p: any) => extractUnixTimeFromUUIDv7(p.id))
    for (let i = 1; i < timestamps.length; i++) {
      expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1])
    }
  })
})

レスポンス時間が1秒以内に返るか

一般的なWeb APIであればレスポンスは遅くとも1秒以内に返ることが理想です。

Web APIのレスポンス時間のテストはバックエンド側で見落とされることがあるので、フロントエンド側で必ず以下のコードでテストしておくと良いです。

products.spec.ts
it('レスポンス時間が1000ms以内に返るか', async () => {
  const start = Date.now()
  const res = await axios.get('https://dummyjson.com/products/1')
  const duration = Date.now() - start
  expect(duration).toBeLessThan(1000)
  expect(res.status).toBe(200)
})

レスポンスヘッダーのテスト

レスポンスヘッダーのテストもレスポンス時間と同様に見落とされやすいです。

レスポンスヘッダーが正しくないとAPIの取得ができなくなるので、APIのcontent-typeやaccess-control-allow-originなどはテストしましょう。

products.spec.ts
it('レスポンス時間が1000ms以内に返るか', async () => {
  const start = Date.now()
  const res = await axios.get('https://dummyjson.com/products/1')
  const duration = Date.now() - start
  expect(duration).toBeLessThan(1000)
  expect(res.status).toBe(200)
})

再取得でデータの一貫性をテスト

Web APIで取得したデータは通常はパラメータが変わったり、タイムスタンプが含まれていなければ同じデータが返ってくるはずです。

しかし、キャッシュやデータベースの不整合などがあると、呼び出すたびにデータが変わるケースがあるので、このテストケースで問題があれば検知できます。

products.spec.ts
it('再取得でデータの一貫性を確認', async () => {
  const res1 = await axios.get('https://dummyjson.com/products')
  const res2 = await axios.get('https://dummyjson.com/products')
  console.log(res1.data.id)
  expect(res1.data).toEqual(res2.data)
})