TypeScript + Jest で、type文やinterface宣言による型からモックオブジェクトを作ってみた

関数 sumItem があり、2つの引数の型 Item があるとします。

export const sumItem = (a: Item, b: Item) => a.unitPrice + b.unitPrice

 
Itemの型は

export type Item = {
    name: string,
    description: string,
    contents: string,
    unitPrice: number,
    note: string,
}

のように、すべて定義が必要だったとします。

 
そんな中、 sumItem 関数に対してテストコードを定義したくなったけど、 Item 型を持つテストデータを用意するのが手間、みたいなことがありました。

(注) 上記例だとそこまで手間ではないですが、実際には以下な感じでした。

  • 上記例では5項目程度ですが、実際にはもっとある
  • 上記例では「sumItemの引数は、内部で使う属性 unitPrice だけにする」こともできますが、実際には複数の属性を利用している
    • そのため、関数名が sumItem というような名前になっています...

 
そこで、「引数の型を元にモックオブジェクトを生成し、テストで必要な属性だけモックオブジェクトにセットする」ができないか試してみたときのメモを残します。

 
なお、この記事よりもより良い書き方があればおしらせください。

 
目次

 

環境

  • TypeScript 5.1.3
  • Jest 29.5.0

 
テスト対象のプロダクションコードは以下です。

なお、TypeScriptでは型を作るためにtype文とinterface宣言があることから、今回はどちらにも対応できるかを試してみます。

type文と関数の場合の実装です。

export type Item = {
    name: string,
    description: string,
    contents: string,
    unitPrice: number,
    note: string,
}
export const sumItem = (a: Item, b: Item) => a.unitPrice + b.unitPrice

 
interface宣言と関数の場合の実装です。

export interface Product {
    name: string,
    description: string,
    contents: string,
    unitPrice: number,
    note: string,
}
export const sumProduct = (a: Product, b: Product) => a.unitPrice + b.unitPrice

 

jest.createMockFromModuleでモックオブジェクトを生成し、jest.Mocked で型注釈する

型からモックオブジェクトを生成するには、Jestの createMockFromModule が使えそうでした。
jest.createMockFromModule(moduleName) | The Jest Object · Jest

また、生成したモックオブジェクトには、 jest.Mock<T> で型注釈を追加しておきます。
Mock Functions · Jest

 
type文を使っている関数のテストコードを createMockFromModule を使って書いてみます。

test('sumItem', () => {
    const a: jest.Mocked<Item> = jest.createMockFromModule<Item>('./sum')
    a.unitPrice = 100

    const b: jest.Mocked<Item> = jest.createMockFromModule<Item>('./sum')
    b.unitPrice = 250

    expect(sumItem(a, b)).toBe(350)

    expect(a.name).toBe(undefined)
    expect(b.name).toBe(undefined)
})

 
ちなみに、モックオブジェクトの生成と値の設定に冗長感があれば、ファクトリ関数を作っても良さそうです。

なお、Item.unitPriceと同じ型を引数として受け取れるようにするため、

  • Pick<Item, 'unitPrice'> で型を抜き出して新しい型を作る
  • その型に対して ['unitPrice'] でunitPriceの型を抜き出す

としています。
参考:Pick<T, Keys> | TypeScript入門『サバイバルTypeScript』

// ファクトリ関数
const itemFactory = (unitPrice: Pick<Item, 'unitPrice'>['unitPrice']): jest.Mocked<Item> => {
    const mockObject: jest.Mocked<Item> = jest.createMockFromModule<Item>('./sum')
    mockObject.unitPrice = unitPrice

    return mockObject
}

// テストコード本体
test('sumItem with factory', () => {
    const a: jest.Mocked<Item> = itemFactory(100)
    const b: jest.Mocked<Item> = itemFactory(250)

    expect(sumItem(a, b)).toBe(350)

    expect(a.name).toBe(undefined)
    expect(b.name).toBe(undefined)
})

 
一方、interface宣言を使った関数の場合のテストコードはこちらです。同じような実装になりました。

test('sumProduct', () => {
    const a: jest.Mocked<Product> = jest.createMockFromModule<Product>('./sum')
    a.unitPrice = 200

    const b: jest.Mocked<Product> = jest.createMockFromModule<Product>('./sum')
    b.unitPrice = 400

    expect(sumProduct(a, b)).toBe(600)

    expect(a.name).toBe(undefined)
    expect(b.name).toBe(undefined)
})

 

jest-mock-extended を使う場合

他に方法がないか調べていたところ、Jestで使うモックをより便利にするライブラリとして jest-mock-extended がありました。 marchaos/jest-mock-extended: Type safe mocking extensions for Jest https://www.npmjs.com/package/jest-mock-extended

そこで、jest-mock-extended の使い心地も試してみます。

 
type文で使うときは以下となります。Jest単体よりも実装量が減りました。

Jest単体との違いとしては、未定義の属性にアクセスすると、 [Function mockConstructor] が返ってくるという点です。

test('sumItem', () => {
    // type文の場合、初期値の設定も行える
    const a: MockProxy<Item> = mock<Item>({unitPrice: 100})
    const b: MockProxy<Item> = mock<Item>({unitPrice: 200})

    expect(sumItem(a, b)).toBe(300)

    // undefinedではなく、 [Function mockConstructor] が返ってくる
    // expect(a.name).toBe(undefined)
    // => Expected: undefined, Received: [Function mockConstructor]
})

 
一方、interface宣言に対して使うときは、 mock にモックの属性の初期値を設定しようとすると型エラーになってしまいます。

const a: MockProxy<Product> = mock<Product>({unitPrice: 100})
// => Argument type {unitPrice: number} is not assignable to parameter type import("ts-essentials").DeepPartial<Product> | undefined

 
そのため、以下のように属性の初期値は後で与える必要があります。なお、未定義の属性にアクセスしたときの挙動はtype文と同じです。

test('sumProduct', () => {
    const a: MockProxy<Product> = mock<Product>()
    a.unitPrice = 100

    const b: MockProxy<Product> = mock<Product>()
    b.unitPrice = 200

    expect(sumItem(a, b)).toBe(300)

    // undefinedではなく、 [Function mockConstructor] が返ってくる
    // expect(a.name).toBe(undefined)
    // => Expected: undefined, Received: [Function mockConstructor]
})

 

参考

Introduce a more flexible and better typed way to mock APIs · Issue #7832 · jestjs/jest

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/typescript_jest-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/typescript_jest-example/pull/1