🚧

テストコード周りの悩みと対応例まとめ【2024】

2024/12/31に公開

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.Runcontextcancel 待ちをするとテストできる。
  • 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