🚧
テストコード周りの悩みと対応例まとめ【2024】
Go
逐次的凝集 (特に interface-adapter (Clean Architecture) レイヤーで頻出)
func (h *handler) SaveRecipe(input model.InputRecipeGroup) (*model.RecipeGroup, error) {
recipeGroups, err := h.Recipes.Find()
if err != nil {
return nil, err
}
recipeTypes, err := h.Recipes.FindRecipeType()
if err != nil {
return nil, err
}
glassTypes, err := h.Recipes.FindGlassType()
if err != nil {
return nil, err
}
return h.convert.RecipeGroup(recipeTypes, glassTypes)(recipeGroups), nil
}
テスト例
結合テスト
- Clean Architecture でいう interface-adapter のレイヤーはユーザーに近い結合部のためテストの重要度は高い
- 時間とお金がかかる部分のみモックし、しっかりと結合テスト
- ブラックボックス重視
単体テスト
-
h.convert.RecipeGroup
へ渡る引数-
h.Recipes.Register(input)
,h.Recipes.FindRecipeType()
,h.Recipes.FindGlassType()
の戻り値と一致すること
-
-
戻り値
-
h.convert.RecipeGroup
の戻り値と一致すること
-
-
実行回数、実行順序が重要な場合はテストする。
-
stretchr/testify/mock.Mock.On.Run
でcontext
のcancel
待ちをするとテストできる。
-
-
if err != nil { }
はreturn
するだけならテストしない-
fmt.Erorrf
で wrap するなら必要に応じてテスト - err を無視していると staticcheck (SA4006) が教えてくれるのでテストしない。
this value of err is never used (SA4006)
-
graceful shutdown
datasources.Recipes.SavePersistently()
がシャットダウン時に実行されてほしい。
func serveGraceful(ctx context.Context, server server, datasources depsDatasources) (err error) {
errServeChan := make(chan error, 1)
go func() {
err := server.ListenAndServe()
if !errors.Is(err, httpgo.ErrServerClosed) {
errServeChan <- err
} else {
close(errServeChan)
}
}()
<-ctx.Done()
errShutdown := server.Shutdown(ctx)
errServe := <-errServeChan
errSave := datasources.Recipes.SavePersistently()
return errors.Join(errServe, errShutdown, errSave)
}
テスト例
t.Run("will call recipe datasource SavePersistently() when canceled", func(t *testing.T) {
t.Parallel()
server := new(MockServer)
server.On("ListenAndServe").Return(nil)
server.On("Shutdown").Return(nil)
recipeDatasource := new(MockRecipeDatasource)
recipeDatasource.On("SavePersistently").Return(nil)
depsDatasources := depsDatasources{Recipes: recipeDatasource}
errChan := make(chan error, 1)
ctx, cancel := context.WithCancel(context.Background())
go func() {
errChan <- serveGraceful(ctx, server, depsDatasources)
}()
time.Sleep(time.Millisecond)
server.AssertCalled(t, "ListenAndServe")
server.AssertNotCalled(t, "Shutdown", mock.Anything)
recipeDatasource.AssertNotCalled(t, "SavePersistently")
cancel()
err := <-errChan
assert.NoError(t, err)
server.AssertCalled(t, "Shutdown", mock.Anything)
recipeDatasource.AssertCalled(t, "SavePersistently")
})
TypeScript / React
useEffect 地獄からの脱却とフローのテスト
以下はファイルアップロード後、artifactURL
を取得するまでの振る舞いをカスタムフックにまとめたもの。
- 結果を受け取る双方向通信の確立
- アップロード処理
- 進捗率の反映
- 結果の取得
export const useUploadContext = () => {
const { state, dispatch } = useContext(UploadContext)
const subscribeRegistry = () =>
subscribe((artifactURL: string, subscription: Subscription) => {
dispatch({ kind: "setResult", payload: { artifactURL } })
subscription.unsubscribe()
})
const upload = async (file: File) => {
const { uploadChunks, combiner } = prepareUploader(ChunkSize["200MB"])
dispatch({ kind: "start" })
subscribeRegistry()
uploadAndCombine(
file,
uploadChunks,
combiner,
/* progress notifier */ (percentage) => {
dispatch({ kind: "setProgress", payload: { progress: percentage } })
},
/* error notifier */ (error) => {
console.error(error)
},
)
}
return { state, upload }
}
テスト例
dispatch
が特定の引数で呼び出されていることをテスト
/**
* @jest-environment jsdom
*/
import { useContext } from "react"
import { useUploadContext } from "./hook"
import { renderHook } from "@testing-library/react"
import { UploadContext, UploadState } from "./context"
import {
Combiner,
ErrorNotifier,
ProgressNotifier,
Uploader,
} from "./lib/upload"
import { ArtifactURLReceiver } from "./lib/pubsub"
jest.mock("react", () => {
return {
__esModule: true,
...jest.requireActual("react"),
useContext: jest.fn().mockReturnValue({
state: { inProgress: false, progress: 0, result: {} } as UploadState,
dispatch: jest.fn(),
}),
}
})
jest.mock("./lib/upload", () => {
return {
__esModule: true,
...jest.requireActual("./lib/upload"),
uploadAndCombine: jest
.fn()
.mockImplementation(
async (
file: File,
upload: Uploader,
combine: Combiner,
notifyProgress: ProgressNotifier,
notifyError: ErrorNotifier,
): Promise<void> => {
notifyProgress(0)
notifyProgress(100)
},
),
}
})
jest.mock("./lib/pubsub", () => {
return {
__esModule: true,
...jest.requireActual("./lib/pubsub"),
subscribe: jest
.fn()
.mockImplementation((onReceiveArtifactURL: ArtifactURLReceiver) => {
onReceiveArtifactURL("https://example.com", { unsubscribe: jest.fn() })
}),
}
})
describe("useUploadContext", () => {
it("will initialize with initial values", () => {
const {
result: {
current: { state },
},
} = renderHook(useUploadContext)
expect(state.inProgress).toBe(false)
expect(state.progress).toBe(0)
expect(state.result.artifactURL).toBe(undefined)
expect(useContext).toHaveBeenCalled()
})
it("will start", () => {
const {
result: {
current: { upload },
},
} = renderHook(useUploadContext)
upload({} as File)
const { dispatch } = useContext(UploadContext)
expect(dispatch).toHaveBeenCalledWith({ kind: "start" })
})
it("will notify upload progress", () => {
const {
result: {
current: { upload },
},
} = renderHook(useUploadContext)
upload({} as File)
const { dispatch } = useContext(UploadContext)
expect((dispatch as jest.Mock).mock.calls).toContainEqual([
{ kind: "setProgress", payload: { progress: 0 } },
])
expect((dispatch as jest.Mock).mock.calls).toContainEqual([
{ kind: "setProgress", payload: { progress: 100 } },
])
})
it("will set received result", () => {
const {
result: {
current: { upload },
},
} = renderHook(useUploadContext)
upload({} as File)
const { dispatch } = useContext(UploadContext)
expect((dispatch as jest.Mock).mock.calls).toContainEqual([
{ kind: "setResult", payload: { artifactURL: "https://example.com" } },
])
})
})
まとめ
- 責務を適切な粒度に分割しスコープを最低限に収める。(大前提)
- 時にはカバレッジをある程度捨てて静的解析ツール、コードレビューに任せてリソースを有効活用する。
- 実行速度、メモリ効率にチューニングを施すならベンチマークもコードを書く。
去年の私だったら「これが趣味で書いてたコードってマジ?馬鹿なの?」って言ってた気がする。
趣味で書くコードでもテストを徹底しないと気がすまなくなってきた。(とても良い傾向)
※自己満
Discussion