🗂

TypeScriptのclassのinstance methodをarrow functionで書くと困ることになるかもしれない話

2024/06/23に公開

はじめに

TypeScriptではclassが使えて、classにはmethodが定義できる。
そのmethodをarrow functionで書くと、テストで困ったことになるかもしれない。

TL;DR;

instance methodをarrow functionで書くと、prototypeに関数が作られずインスタンスごとに異なる参照で関数が作られてしまい、インスタンスの同一性を確認するテストが落ちる。

この記事にもそんなことが書いてあった。
https://dev.to/sakhnyuk/why-you-should-avoid-using-arrow-functions-to-define-class-methods-213e

実際にやってみる

以下のように2種類のclassを作る。

class SampleA {
  readonly a: string
  constructor(a: string) {
    this.a = a
  }

  sampleMethod = () => this.a
}

class SampleB {
  readonly b: string
  constructor(b: string) {
    this.b = b
  }

  sampleMethod() {
    return this.b
  }
}

SampleAではarrow functionを、SampleBでは通常のfunctionを使ってメソッドを定義してある。

そして以下のようなテストで、同じpropertyを持つインスタンスをそれぞれ比較しつつ、挙動の違いを確認する。

describe('Sample', () => {
  describe('Test SampleA', () => {
    test('同じかどうか確認', () => {
      const sampleA1 = new SampleA('a')
      const sampleA2 = new SampleA('a')

      expect(sampleA1).toEqual(sampleA2)
    })

    test('メソッドの挙動を確認 (メソッドとして使う)', () => {
      const sampleA1 = new SampleA('a')

      expect(sampleA1.sampleMethod()).toEqual('a')
    })

    test('メソッドの挙動を確認 (独立した関数として使う)', () => {
      const sampleA1 = new SampleA('a')

      const { sampleMethod } = sampleA1
      expect(sampleMethod()).toEqual('a')
    })
  })

  describe('Test SampleB', () => {
    test('同じかどうか確認', () => {
      const sampleB1 = new SampleB('b')
      const sampleB2 = new SampleB('b')

      expect(sampleB1.sampleMethod()).toEqual('b')
      expect(sampleB1).toEqual(sampleB2)
    })

    test('メソッドの挙動を確認 (メソッドとして使う)', () => {
      const sampleB1 = new SampleB('b')

      expect(sampleB1.sampleMethod()).toEqual('b')
    })

    test('メソッドの挙動を確認 (独立した関数として使う)', () => {
      const sampleB1 = new SampleB('b')

      const { sampleMethod } = sampleB1
      expect(sampleMethod()).toEqual('b')
    })
  })
})

すると、こうなる。
今回はvitestを使ったが、多分jestでも同じだと思う。

 ❯ sample.test.ts (6)
   ❯ Sample (6)
     ❯ Test SampleA (3)
       × 同じかどうか確認
       ✓ メソッドの挙動を確認 (メソッドとして使う)
       ✓ メソッドの挙動を確認 (独立した関数として使う)
     ❯ Test SampleB (3)
       ✓ 同じかどうか確認
       ✓ メソッドの挙動を確認 (メソッドとして使う)
       × メソッドの挙動を確認 (独立した関数として使う)

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  sample.test.ts > Sample > Test SampleA > 同じかどうか確認
AssertionError: expected SampleA{(2) } to deeply equal SampleA{(2) }

Compared values have no visual difference.

 ❯ src/models/sample.test.ts:27:24
     25|       const sampleA2 = new SampleA('a')
     26|
     27|       expect(sampleA1).toEqual(sampleA2)
       |                        ^
     28|     })
     29|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL  sample.test.ts > Sample > Test SampleB > メソッドの挙動を確認 (独立した関数として使う)
TypeError: Cannot read properties of undefined (reading 'b')
 ❯ sampleMethod src/models/sample.test.ts:17:17
     15|
     16|   sampleMethod() {
     17|     return this.b
       |                 ^
     18|   }
     19| }
 ❯ src/models/sample.test.ts:63:14

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯

 Test Files  1 failed (1)
      Tests  2 failed | 4 passed (6)
  • SampleA

    • 🙅 等価性確認のテスト
      • メッセージは Compared values have no visual difference. なので、具体的に何が悪いのかはわからない。
    • 🙆 メソッドの動作のテスト
    • 🙆 メソッドを独立した関数として使う場合のテスト
  • SampleB

    • 🙆 等価性確認のテスト
    • 🙆 メソッドの動作のテスト
    • 🙅 メソッドを独立した関数として使う場合のテスト
      • this がundefined。

教訓

  • 関数にはarrow functionを使うことにしているプロジェクトでも、インスタンスの等価性をテストしたい場合はインスタンスメソッドの定義にはarrow functionは使ってはいけない。
  • ただし、通常の関数としてインスタンスメソッドを定義するとthisが関数にbindされない。以下のように、明示的にthisをbindする必要がある。
  • 初歩的なことだが、実際の業務でこういうことで悩まされることは意外と少ない気がする。
    test('メソッドの挙動を確認 (独立した関数として使い、明示的にbindする)', () => {
      const sampleB1 = new SampleB('b')

      const { sampleMethod } = sampleB1
      const bounded = sampleMethod.bind(sampleB1)
      expect(bounded()).toEqual('b')
    })

終わりに

tsでは可能な限りピュアなobjectで済ませるのがなんだかんだ楽な気がする。

Discussion