13
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Test Double】Fake、使ってますか?

Last updated at Posted at 2024-09-08

前書き

全ての偽物を「Mock」そう呼んでいませんか?

Testingで偽物を活用することを「モック(⁠Mock)する」と呼ぶことが多いですが、正確には、テストに偽物を使う技術を総称して「テストダブル」と呼びます。

ここでの「ダブル」は「スタントダブル(Stunt Double)」が由来となっています。
映画やドラマに出演する俳優が危険な状況を演じなければならない時、アクションのプロではない彼らが怪我をしないように、彼らにそっくりな「スタントマン」が代わりにアクションシーンを演じます。そうすることで、まるで俳優が派手なアクションを演じているかのような作品が出来上がるわけです。

image.png

Testingにおいても同じことです。
実際の通信を行うオブジェクト(本物)をテストで使ってしまうと、外部APIを実際に呼び出してしまったりします。外部APIは基本的に自分では制御することができないため、テストをすることがそもそもできません。

そこで、Test Doubleの出番です!


Test Doubleには5種類あります

  • Spy(呼ばれたこと、渡した引数が正しいことを確認するダブル)
  • Stub(返り値を固定するダブル)
  • Dummy(無関係の依存であることを明確にするダブル)
  • Mock(自己検証機能を持つSpy。Spyのリファクタリングの副産物)
  • Fake(本物に近い、簡素な実装を持つダブル。実装を含んだStub)

全てをまとめて「Mock」と呼んでしまうと

「このユニットテストにおいて、この依存は何のためにあるのか?」

が、ものすごく抽象的になってしまいます。
そのため、しっかりと名前と役割を分けて、読み手がテストの意図を掴みやすくするためにも、テストダブルは必要なのです


今回は、意外と使い所が掴みにくい、Fake についての記事です

記事めちゃ長くなっちゃいましたすんません

Fakeとは?

テストにおいて「あたかも本物かのように振る舞うオブジェクト」のことです。
返り値を固定するだけのStubと比べると、Fakeは簡単な実装を含みます。

言い換えると、

Stubにロジックが含まれたら、それは Fake と呼ぶ。

ということになります。

そして、「Fakeはロジックを含む」ということは
Fake自体のテストが必要ということをお忘れなく。

Fakeの使い所

私的な Fake の使いどころを紹介します

  1. Stubではテストがうまく書けない時
  2. Stubにロジックを持たせたくなった時
  3. 本物を使っても問題ないテストだけど、本物の実装が複雑なため、テストのアレンジが大変な時

の2つだと思っています。具体例をコードで説明していきます

登場人物

  • SchoolService : 学校の情報(生徒、先生)を処理するロジックを持つ層
  • SchoolManager : 学校を管理する層

SchoolService

SchoolServiceは下記を定義するinterfaceです

  • setHumans(humanTexts: string[]) : 受け取った文字列が生徒か先生の情報かを判断して、studentsteachersにそれぞれ値をセットする
  • getStudents(): Student[] : 生徒の配列を返す
  • getTeachers(): Teacher[] : 先生の配列を返す

DefaultSchoolServiceが実装クラスです

SchoolService.ts
export default interface SchoolService {
    setHumans(humanTexts: string[]): void
    getStudents(): Student[]
    getTeachers(): Teacher[]
}

export class DefaultStudentService implements SchoolService {
    students: Student[] = []
    teachers: Teacher[] = []

    setHumans(humanTexts: string[]) {
        const by = (human: Human) => (text: string) => {
            return text.split(' ')[0] === human
        }

        this.students = humanTexts
            .filter(by(Human.STUDENT))
            .map(texts => this.createStudent(texts))

        this.teachers = humanTexts
            .filter(by(Human.TEACHER))
            .map(texts => this.createTeacher(texts))
    }

    getStudents(): Student[] {
        return this.students
    }

    getTeachers(): Teacher[] {
        return this.teachers
    }

    private createStudent(text: string): Student {
        const splitText = text.split(' ')
        return {
            name: splitText[1],
            age: +splitText[2],
            birthday: new Date(splitText[3]),
            attendanceNumber: +splitText[4],
            gradeInSchool: +splitText[5],
            club: splitText[6],
        }
    }

    private createTeacher(text: string): Teacher {
        const splitText = text.split(' ')
        return {
            name: splitText[1],
            age: +splitText[2],
            birthday: new Date(splitText[3]),
            subject: splitText[4],
            employmentPosition: splitText[5],
            advisorTo: splitText[6],
        }
    }
}

使用するモデルの定義は下記です

// 生徒の情報を持つモデル
export interface Student {
    name: string
    age: number
    birthday: Date
    attendanceNumber: number
    gradeInSchool: number
    club: string
}

// 先生の情報を持つモデル
export interface Teacher {
    name: string
    age: number
    birthday: Date
    subject: string
    employmentPosition: string
    advisorTo: string
}

// 人の列挙型
enum Human {
    STUDENT = 'student',
    TEACHER = 'teacher',
}

SchoolServiceのテストコード

この層のテストは今回Fakeを使うところではありませんが、仕様を伝えるためにテストを載せます。
注目して欲しいのは studentService.setHumans に渡す引数です。
半角スペース区切りのテキストからstudent or teacherを判断し、名前や年齢、誕生日(Date型)などの情報を抜き取り、オブジェクトにするという処理です
こういうテキストのハンドリングは、ロジカルじゃなくて正直めんどくさいですよね

この例は、説明の便宜上あえて複雑な仕様にしています。
ここで押さえておいて欲しいのは

setHumansには、結構面倒臭いテキストを渡さないといけない

ということです。
SchoolManagerからこのメソッドを呼び出すテストをこの後書きますが
この面倒臭さがどう影響してくるのかを実感してもらいたいです!

まずはSchoolServiceのテストコードです

describe('DefaultStudentService', () => {
    describe('setHumans', () => {
        test('受け取った文字列を正しいオブジェクトに変換し、students,teachersに正しくセットする', () => {
            const studentText = 'student taro 18 2006-01-01 1 3 baseball'
            const teacherText = 'teacher jiro 25 1998-03-03 数学 一般 baseball'
            const studentService = new DefaultStudentService()


            studentService.setHumans([
                studentText,
                teacherText,
            ])


            const expectedStudents: Student[] = [
                {
                    attendanceNumber: 1,
                    name: 'taro',
                    age: 18,
                    birthday: new Date('2006-01-01'),
                    gradeInSchool: 3,
                    club: 'baseball',
                },
            ]
            const expectedTeachers: Teacher[] = [
                {
                    name: 'jiro',
                    age: 25,
                    birthday: new Date('1998-03-03'),
                    subject: '数学',
                    employmentPosition: '一般',
                    advisorTo: 'baseball',
                },
            ]
            expect(studentService.students).toEqual(expectedStudents)
            expect(studentService.teachers).toEqual(expectedTeachers)
        })
    })

    describe('getStudents', () => {
        // メンバーのstudentsを返すだけなのでここでは省略
    })

    describe('getTeachers', () => {
        // メンバーのteachersを返すだけなのでここでは省略
    })
})

SchoolManager

SchoolManagerは下記を定義するinterfaceです

  • schools : 学校の情報を配列で保持するメンバー変数
  • createSchools : 引数で受け取った文字列の配列を元に、学校の情報を作るメソッド

DefaultSchoolManager が実装クラスです
先ほどの SchoolService に依存しています

SchoolManager.ts 最終形態
export default interface SchoolManager {
    schools: School[]
    createSchools(schoolSummaries: SchoolSummary[])
}

export class DefaultSchoolManager implements SchoolManager {
    constructor(
        private schoolService: SchoolService = new DefaultStudentService(),
    ) {}
    
    schools: School[] = []

    createSchools(schoolSummaries: SchoolSummary[]) {
        schoolSummaries.forEach(schoolSummary => {
            this.schoolService.setHumans(schoolSummary.humanTexts)
            const school: School = {
                name: schoolSummary.name,
                students: this.schoolService.getStudents(),
                teachers: this.schoolService.getTeachers(),
            }
            this.schools.push(school)
        })
    }
}

使用するモデルの定義は下記です

// 学校名、生徒の配列、先生の配列を持つモデル
export interface School {
    name: string
    students: Student[]
    teachers: Teacher[]
}

// 学校名、人の情報を持つ文字列の配列、を持つモデル
export interface SchoolSummary {
    name: string
    humanTexts: string[]
}

SchoolManagerのテストコード

createSchools メソッドのテストにて、今回のFakeの使いどころを深掘っていきます

※TestDoubleの記事ですが、TDD(テスト駆動開発) の大切さも同時に伝えていけたらなと思っています


⚫︎Test Case 1

まずはschoolnameを正しく保存するかどうかのテストケースです
これは単純ですね、2つのSchoolSummaryを準備してcreateSchoolsに渡し、
その結果schoolManager.schoolsに正しいものが保存されていればいいので
このようなテストが書けます

この時のSchoolServiceはこのテストにとって「関係のない依存」なので
DummySchoolServiceをDI(依存注入)します。関係ないので明示的にDummyにします。

テストを回すと1つ目のアサーションで落ちます

テスト
test('受け取ったSchoolSummaryのnameを、メンバーのschoolsに正しく保存する', () => {
    const schoolSummaries = [
        {name: 'Hoge High School', humanTexts: []},
        {name: 'Fuga High School', humanTexts: []},
    ]
    const dummySchoolService = new DummySchoolService()
    const schoolManager = new DefaultSchoolManager(dummySchoolService)

    
    schoolManager.createSchools(schoolSummaries)


    expect(schoolManager.schools).toHaveLength(2) // ❌ Fail: expected [] to have a length of 2 but got 0
    expect(schoolManager.schools[0].name).toBe('Hoge High School')
    expect(schoolManager.schools[1].name).toBe('Fuga High School')
})
DummySchoolService
export class DummySchoolService implements SchoolService {
    setHumans(humanTexts: string[]): void {}

    getStudents(): Student[] {
        return []
    }

    getTeachers(): Teacher[] {
        return []
    }
}

実装も簡単ですね、今はこれだけでテストが通ります。

実装
createSchools(schoolSummaries: SchoolSummary[]) {
    schoolSummaries.forEach(schoolSummary => {
        const school: School = {
            name: schoolSummary.name,
            students: [],
            teachers: [],
        }
        this.schools.push(school)
    })
}

// 🟢 Test passed!!

⚫︎Test Case 2

次は、受け取った humanTextsschoolService.setHumans()に渡しているかのテストケースです。
このあと、schoolstudentsteachersに正しい値を保存するかのテストをする必要があり、そのためにはschoolService.setHumans()を前もって実行している必要があるので、このテストを先に行います。

この時のSchoolServiceはこのテストにとって
必要な引数を渡しているか、正しく呼んでいるか」を確かめるための役割なので
SpySchoolService をDI(依存注入)します。今回はSpyです。

テストを回すと1つ目のアサーションで落ちます。
まだsetHumansを一度も呼んでいないからです。

テスト
test('受け取ったSchoolSummaryのhumanTextsを、schoolServiceのcreateSchoolsに正しく渡して呼んでいる', () => {
    const schoolSummaries = [
        {name: '', humanTexts: ['human text 1']},
        {name: '', humanTexts: ['human text 2']},
    ]
    const spySchoolService = new SpySchoolService()
    const schoolManager = new DefaultSchoolManager(spySchoolService)


    schoolManager.createSchools(schoolSummaries)

    
    expect(spySchoolService.setHumans_argument_humanTexts_logs)
        .toHaveLength(2) // ❌ Fail: expected [] to have a length of 2 but got 0
    expect(spySchoolService.setHumans_argument_humanTexts_logs[0])
        .toEqual(['human text 1'])
    expect(spySchoolService.setHumans_argument_humanTexts_logs[1])
        .toEqual(['human text 2'])
})

ちなみにSpyはこのように作っています。
setHumansが呼ばれる度に、setHumans_argument_humanTexts_logsに引数のhumansTextsを保存するようにしています。
こうすることで、複数回呼ばれた時にも正しい引数を受け取ったかどうかをテストできます

SpySchoolService
export class SpySchoolService implements SchoolService {
    setHumans_argument_humanTexts_logs: string[][] = []
    setHumans(humanTexts: string[]): void {
        this.setHumans_argument_humanTexts_logs.push(humanTexts)
    }

    getStudents(): Student[] {
        return []
    }

    getTeachers(): Teacher[] {
        return []
    }
}

実装はこうなります。
this.schoolService.setHumans に必要な引数を渡して呼ぶだけでテストが通ります

実装
createSchools(schoolSummaries: SchoolSummary[]) {
    schoolSummaries.forEach(schoolSummary => {
        this.schoolService.setHumans(schoolSummary.humanTexts)
        const school: School = {
            name: schoolSummary.name,
            students: [],
            teachers: [],
        }
        this.schools.push(school)
    })
}

// 🟢 Test passed!!

⚫︎Test Case 3

次は、単一のSchoolSummaryを受け取った場合、schoolsstudentsteachersに正しい値を保存するかのテストケースです。
SchoolServicesetHumans()が実行されると、getStudents()getTeachers()が正しい値を返す想定なので、今回はgetStudents()getTeachers()の返り値を固定して、その値をschoolsに正しく保存しているか、というアプローチでテストを書きます

この時のSchoolServiceはこのテストにとって
返り値を固定する」ための役割なので
StubSchoolService をDI(依存注入)します。今回はStubです。

テスト
test('受け取った単一のSchoolSummaryのhumanTextsを、メンバーのschoolsに正しく保存する', () => {
    const schoolSummary: SchoolSummary = {name: '', humanTexts: []}
    const stubSchoolService = new StubSchoolService()
    stubSchoolService.getStudents_returnValue = [
        {
            name: 'taro',
            age: 0,
            birthday: new Date(),
            attendanceNumber: 0,
            gradeInSchool: 0,
            club: ''
        }
    ]
    stubSchoolService.getTeachers_returnValue = [
        {
            name: 'jiro',
            age: 0,
            birthday: new Date(),
            subject: '',
            employmentPosition: '',
            advisorTo: '',
        }
    ]
    const schoolManager = new DefaultSchoolManager(stubSchoolService)


    schoolManager.createSchools([schoolSummary])


    expect(schoolManager.schools[0].students).toHaveLength(1)
    expect(schoolManager.schools[0].students[0].name).toEqual('taro')
    expect(schoolManager.schools[0].teachers).toHaveLength(1)
    expect(schoolManager.schools[0].teachers[0].name).toEqual('jiro')
})

Stubはこのようになっています。
*_returnValueというパブリックプロパティを用意し、外から返り値を決めれるようにしています。

StubSchoolService
export class StubSchoolService implements SchoolService {
    setHumans(humanTexts: string[]): void {
    }

    getStudents_returnValue: Student[] = []
    getStudents(): Student[] {
        return this.getStudents_returnValue
    }

    getTeachers_returnValue: Teacher[] = []
    getTeachers(): Teacher[] {
        return this.getTeachers_returnValue
    }
}

さて、TDD的にいうと、このテストをパスするための最低限の実装はどうなるでしょうか?

こうなります。
このテストであれば、createSchools()に渡されるschoolSummariesの要素は1つなので、ベタ書きでテストを通すことができちゃいますね。

実装
createSchools(schoolSummaries: SchoolSummary[]) {
    schoolSummaries.forEach(schoolSummary => {
        this.schoolService.setHumans(schoolSummary.humanTexts)
        const school: School = {
            name: schoolSummary.name,
            students: [
                {
                    name: 'taro',
                    age: 0,
                    birthday: new Date(),
                    attendanceNumber: 0,
                    gradeInSchool: 0,
                    club: ''
                }
            ],
            teachers: [
                {
                    name: 'jiro',
                    age: 0,
                    birthday: new Date(),
                    subject: '',
                    employmentPosition: '',
                    advisorTo: '',
                }
            ],
        }
        this.schools.push(school)
    })
}

// 🟢 Test passed!!

⚫︎Test Case 4 (Stub編)

では次は、複数のSchoolSummaryを受け取った場合でも、schoolsstudentsteachersに正しい値を保存するかのテストケースです。

Test Case 3 と同様にStubを使ってテストを書けるでしょうか?
結論 「できるけど、分かりにくいし、やりたくない」 です。

もちろん無理やり書くことはできます

無理やりテスト
test('受け取った複数のSchoolSummaryのhumanTextsを、メンバーのschoolsに正しく保存する', () => {
    const schoolSummaries: SchoolSummary[] = [
        {name: '', humanTexts: []},
        {name: '', humanTexts: []},
    ]
    const stubSchoolService = new StubSchoolService()
    stubSchoolService.getStudents_returnValues = [
        [StudentBuilder.build({name: 'ichiro'})],
        [StudentBuilder.build({name: 'jiro'})],
    ]
    stubSchoolService.getTeachers_returnValues = [
        [TeacherBuilder.build({name: 'saburo'})],
        [TeacherBuilder.build({name: 'shiro'})],
    ]
    const schoolManager = new DefaultSchoolManager(stubSchoolService)


    schoolManager.createSchools(schoolSummaries)


    expect(schoolManager.schools[0].students).toHaveLength(1)
    expect(schoolManager.schools[0].students[0].name).toEqual('ichiro')
    expect(schoolManager.schools[0].teachers).toHaveLength(1)
    expect(schoolManager.schools[0].teachers[0].name).toEqual('saburo')
    expect(schoolManager.schools[1].students).toHaveLength(1)
    expect(schoolManager.schools[1].students[0].name).toEqual('jiro')
    expect(schoolManager.schools[1].teachers).toHaveLength(1)
    expect(schoolManager.schools[1].teachers[0].name).toEqual('shiro')
})

Stubをこのように改造すれば、できないことはないです。

StubSchoolService
export class StubSchoolService implements SchoolService {
    setHumans(humanTexts: string[]): void {
    }

    getStudents_callCount = 0
    getStudents_returnValues: Student[][] = []
    getStudents(): Student[] {
        return this.getStudents_returnValues[this.getStudents_callCount++]
    }

    getTeachers_callCount = 0
    getTeachers_returnValues: Teacher[][] = []
    getTeachers(): Teacher[] {
        return this.getTeachers_returnValues[this.getTeachers_callCount++]
    }
}

getStudents_returnValuesに返り値としてセットしたい値を複数配列で準備しておき、getStudents getTeachersの呼ばれた回数を*_callCoundで保持しておき、インデックスアクセスで返り値を決める

という荒技です

でもこれって
「Stubは返り値を固定する」のルールに反していませんか?
インデックスによって可変してたら、それはもうStubではないと私は思ってしまいます。
何より、インデックスアクセスのロジックが既に含まれているので、偽物のテストがあってもいいのでは?とも思います。

こういった理由から、このようなケースではStubは適しません。


⚫︎Test Case 4 (本物編)

複数のSchoolSummaryを受け取った場合でも、schoolsstudentsteachersに正しい値を保存するかのテストケースに対して、Stubは適さないので

「じゃあ、本物使っちゃう?」

という発想を一度試してみたいと思います

テストはこうなります。

テスト
test('受け取った複数のSchoolSummaryのhumanTextsを、メンバーのschoolsに正しく保存する', () => {
    const schoolSummaries: SchoolSummary[] = [
        {
            name: '',
            humanTexts: [
                'student ichiro 25 1998-03-03 1 3 baseball',
                'teacher jiro 25 1998-03-03 数学 一般 baseball'
            ]
        },
        {
            name: '',
            humanTexts: [
                'student saburo 25 1998-03-03 1 3 baseball',
                'teacher shiro 25 1998-03-03 数学 一般 baseball'
            ]
        },
    ]
    const defaultSchoolService = new DefaultStudentService()
    const schoolManager = new DefaultSchoolManager(defaultSchoolService)


    schoolManager.createSchools(schoolSummaries)


    expect(schoolManager.schools[0].students).toHaveLength(1)
    expect(schoolManager.schools[0].students[0].name).toEqual('ichiro')
    expect(schoolManager.schools[0].teachers).toHaveLength(1)
    expect(schoolManager.schools[0].teachers[0].name).toEqual('jiro')
    expect(schoolManager.schools[1].students).toHaveLength(1)
    expect(schoolManager.schools[1].students[0].name).toEqual('saburo')
    expect(schoolManager.schools[1].teachers).toHaveLength(1)
    expect(schoolManager.schools[1].teachers[0].name).toEqual('shiro')
})

注目すべき点は

  • 'student ichiro 25 1998-03-03 1 3 baseball' のように、本物の実装でエラーなく、期待されるstudentsteachersが代入されるアレンジを、テストで正確に書く必要がある
  • 1998-03-03のように、Date型に適する文字列を正確に書かなければいけない
  • getStudents()が返す値をschoolsにセットしている」ことを確かめるだけでいいので、最低限名前の情報だけアレンジすればいいのに、日付や部活の情報も入れなければいけない

冒頭で説明しましたが、本物の実装は文字列の少々複雑な処理を行なっています。
本物を使うということは、
「本物DefaultSchoolServiceの実装が上手くいくように」テストでアレンジを作らないといけないんですね。

これってすごく面倒ですよね。
SchoolManagerの単体テストなのにSchoolServiceの実装のことを考えてしまっている、つまり「余計なことを考える」必要が出てくるんですよね。

これを解決するのが、Fakeというわけです。


⚫︎Test Case 4 (Fake編)

前置きが長くなりすぎましたが、、、笑
いよいよFakeを使ってテストをしていきます。

まず、FakeSchoolServiceのテストを書いて、実装をしていきます。
ここでもお伝えした通り、Fakeには実装が含まれるので、Fakeに対してもテストが必要になります

FakeSchoolServiceのテスト
describe('FakeSchoolService', () => {
    describe('setHumans', () => {
        test('受け取った文字列からstudentかteacherかを判断し、正しくStudentをセットする', () => {
            const studentText = 'student1'
            const teacherText = 'teacher1'
            const fakeStudentService = new FakeSchoolService()


            fakeStudentService.setHumans([
                studentText,
                teacherText,
            ])


            expect(fakeStudentService.students).toHaveLength(1)
            expect(fakeStudentService.students[0].name).toEqual('student1')
            expect(fakeStudentService.teachers).toHaveLength(1)
            expect(fakeStudentService.teachers[0].name).toEqual('teacher1')
        })
    })
})
FakeSchoolServiceの実装
export class FakeSchoolService implements SchoolService {
    students: Student[]
    teachers: Teacher[]

    setHumans(humanTexts: string[]): void {
        const by = (human: Human) => (text: string) => {
            return text.includes(human)
        }

        this.students = humanTexts
            .filter(by(Human.STUDENT))
            .map(humanText => StudentBuilder.build({name: humanText}))

        this.teachers = humanTexts
            .filter(by(Human.TEACHER))
            .map(humanText => TeacherBuilder.build({name: humanText}))
    }

    getStudents(): Student[] {
        return this.students
    }

    getTeachers(): Teacher[] {
        return this.teachers
    }
}
StudentBuilder, TeacherBuilderの説明はこちら

デザインパターンの一つ、Builder Patternです。

export class StudentBuilder {
    static build(overrides: Partial<Student> = {}): Student {
        return {
            name: '',
            age: 0,
            birthday: new Date(),
            attendanceNumber: 0,
            gradeInSchool: 0,
            club: '',
            ...overrides,
        }
    }
}

export class TeacherBuilder {
    static build(overrides: Partial<Teacher> = {}): Teacher {
        return {
            name: '',
            age: 0,
            birthday: new Date(),
            subject: '',
            employmentPosition: '',
            advisorTo: '',
            ...overrides,
        }
    }
}

実装は、本物と比べてすごくシンプルです。
setHumans()の行数自体はあまり変わりませんが、大きく違うのは

  • humanTextstudentの文字列が含まれるものにfilterをかけ、nameにテキストをそのまま代入して他だけのStudentオブジェクトを生成し、studentsメンバーに保存する
  • humanTextteacherの文字列が含まれるものにfilterをかけ、nameにテキストをそのまま代入して他だけのTeacherオブジェクトを生成し、teachersメンバーに保存する

このFakeの責任は 
「与えられたテキストから生徒か先生かを判断し、誰かを判断できる文字列をnameに入れて保存するだけ」です
Date型がどうとか、数値型がどうとか、name以外の不要な情報を考える必要がなくなりました。

では、このFakeを使って SchoolManager のテストを書いてみましょう

FakeSchoolServiceを使ったテスト
test('fake: 受け取った複数のSchoolSummaryのhumanTextsを、メンバーのschoolsに正しく保存する', () => {
    const schoolSummaries: SchoolSummary[] = [
        SchoolSummaryBuilder.build({humanTexts: ['student1', 'teacher1']}),
        SchoolSummaryBuilder.build({humanTexts: ['student2', 'teacher2']}),
    ]
    const fakeSchoolService = new FakeSchoolService()
    const schoolManager = new DefaultSchoolManager(fakeSchoolService)


    schoolManager.createSchools(schoolSummaries)


    expect(schoolManager.schools[0].students).toHaveLength(1)
    expect(schoolManager.schools[0].students[0].name).toEqual('student1')
    expect(schoolManager.schools[0].teachers).toHaveLength(1)
    expect(schoolManager.schools[0].teachers[0].name).toEqual('teacher1')
    expect(schoolManager.schools[1].students).toHaveLength(1)
    expect(schoolManager.schools[1].students[0].name).toEqual('student2')
    expect(schoolManager.schools[1].teachers).toHaveLength(1)
    expect(schoolManager.schools[1].teachers[0].name).toEqual('teacher2')
})
SchoolSummaryBuilderのコードはこちら
class SchoolSummaryBuilder {
    static build(overrides: Partial<SchoolSummary> = {}): SchoolSummary {
        return {
            name: '',
            humanTexts: [],
            ...overrides,
        }
    }
}
  • humanTexts に対して ['student1', 'teacher1']のように、FakeSchoolService.setHumans()の簡単な実装を満たす最低限の情報を設定する(日付の文字列などは入れなくてOK!)
  • FakeSchoolServiceによって、生徒と先生はしっかり振り分けられるので、getStudents()getTeachers()の返り値は期待通りに決まる
  • createSchoolsが期待通りにschoolsを作成してくれるはず

という状況を作れました

このようにテストが書けたら、SchoolManager.createSchools()の実装をしましょう

SchoolManagerの最終的な実装
export class DefaultSchoolManager implements SchoolManager {
    constructor(
        private schoolService: SchoolService = new DefaultStudentService(),
    ) {}

    schools: School[] = []

    createSchools(schoolSummaries: SchoolSummary[]) {
        schoolSummaries.forEach(schoolSummary => {
            this.schoolService.setHumans(schoolSummary.humanTexts)
            const school: School = {
                name: schoolSummary.name,
                students: this.schoolService.getStudents(),
                teachers: this.schoolService.getTeachers(),
            }
            this.schools.push(school)
        })
    }
}

// 🟢 Test passed!!

テストがパスし、見事実装完了しました!!

createSchoolsの責任は「schoolService.getStudents(), schoolService.getTeachers() の返り値を使ってschoolsを正しく作る」ことなので、schoolServiceの実装がどうなっているかを知る必要がありません。
Fakeを使うと、役割を明確に分けることができ、ほぼテスト対象のメソッドのことだけを考えれば良くなるというわけです。

まとめ

Fakeのおさらいです

⚫︎Fakeとは

  • テストにおいて「あたかも本物かのように振る舞うオブジェクト」のこと
  • 返り値を固定するだけのStubと比べると、Fakeは簡単な実装を含む
  • Fakeは実装を含むので、Fake自体のテストが必要になる

⚫︎Fakeを使うタイミング

  • 「あれ、Stubで返り値固定しちゃうと上手いことテストできないな」と思った時
  • 「Stubに実装入ってたらテスト楽だな」と思った時(こうなるとそれはもうStubではなくFakeである)
  • 「通信を行うわけじゃないし、本物使ったほうがテスト楽じゃね? でも実装複雑でテストのアレンジが煩雑になるな」と思った時

さいごに

開発していて、どうしてもテストが書きづらい場面って絶対に訪れると思います。
そんな時にこの記事の記憶がフワッと蘇って
「Fakeだったらいけちゃう?」と、アイデアの1つにFakeがなってくれたら、嬉しいです。

Fake以外のダブルについての記事は、気が向いたら書きます

こんなに長い記事を読んでいただいてありがとうございました!

13
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?