- 投稿日:
単体テストは、コードの品質を向上させ、開発効率を高めるために欠かせないものだ。本記事では、単体テストを導入することで得られるメリットとその理由について解説する。
単体テストを書くことについて
単体テストを書くことには賛否両論あると思うが、私は書いたほうが良いと考えている。これは単にテストコードを書くことによってデグレが起きづらくなるからだけではなく、可読性の向上やコンフリクト頻度の減少など、様々な恩恵があるからだ。
この記事では、単体テストを書くことで得られるメリットについて書いていく。特に、関数を活用した開発(クラスを使わないスタイル)、TypeScriptでReactの関数コンポーネントを書くようなケースを前提に話を進める。
関数が小さくなる
単体テストの目的は、関数やクラスといった単位でその振る舞いを検証することだ。しかし、もし関数が1000行に及び、クロージャや分岐、ループが複雑にネストされていると、テストは非常に困難になる。つまり、これを回避しようとすると、自然と関数のサイズは小さくなり、責務が一つにまとまる設計となる。
関数が小さくなることでスコープが短くなり、見通しが良くなるほか、変数名を簡略化できる利点も生まれる。長すぎる変数名は基本的に有害だ。例えば以下のようなコードは誰も見たくないはずだ。(しかもよく見ると罠のようにも見えるコードだ)
const loooooooooooooooooooongVariableNameExtraResult = `${loooooooooooooooooooongVariableNameExtraParameter1} ${loooooooooooooooooooongVariableNameExtraParameter3} ${shortVar} ${loooooooooooooooooooongVariableNameExtraParameter2}`;
例えば、Go言語では一文字変数が一般的に受け入れられているが、これは関数のスコープが非常に短く、簡潔なコードが成立しやすい文化があるからだ。
疎結合な設計にしやすくなる
密結合なコードはテストがしづらい。そのため、単体テストを書こうとすると、自然と疎結合な設計になりやすくなる。具体的には、依存性逆転の原則に従った設計を採用するようになる。
例えば、次のようなコードはDate ()
を直接使用しており、テストが困難だ。このような場合、Date ()
をモックするために専用のライブラリやハックを使うことが多くなり、プログラムの保守性が悪化しがちだ。しかし、依存を外部から注入する設計にすれば、そういった心配は不要になる。
const isOneDayLater = (time: number) => {
return (+new Date() - time) > 86_400_000;
};
しかし、以下のように外部から値を注入すればモックしなくともテストコードを書くのが容易になる。第二引数に値を入れればいいからだ。
const isOneDayLater = (time: number, baseEpochTime: number) => {
return (baseEpochTime - time) > 86_400_000;
};
コンフリクトが起きづらくなる
Gitを利用した並行開発においてはブランチをマージするときにコンフリクト、つまりコードの競合が起きることがしばしばある。しかしこれまでに書いたように責務別にモジュール化された簡素なコードではコンフリクトの規模が自然に小さくなり、コンフリクト自体が減ったり、万一起きた時でも影響範囲が限定されるようになる。
コンフリクトの解消ミスがあり本番障害が出た、後続のQAフェーズなどで差し戻しが起きたみたいなのはよくある光景だと思うが、単体テストの導入による設計の改善があれば、それが起きた時のコストを減らすのに貢献することが可能だ。
コードの見通しが良くなる
適切に単体テストを意識してコードを書くと、自然と「べた書き」から「モジュール化」されたコードに変わる。
べた書きされたコードの例
例えば次のような関数は冗長だ。この程度の長さならまだしも、これが500行、1000行と増えていくと読むのが大変になる。現実ではもっとネストが酷かったり、前後に長大関数がいて、注意深く読まないと解読不能ということもままある。そしてこの関数をテストするのは書いてある処理すべてを検査する必要があるので、テストコードも長くなり、しんどい。
export const registerUser = async (user: User) => {
if (!/^\d+$/.test(user.id)) {
throw new Error('ユーザーIDの書式が不正です。');
}
if (user.firstName === '') {
throw new Error('姓が入力されていません。');
}
if (user.lastName === '') {
throw new Error('名が入力されていません。');
}
if (user.gender === '') {
throw new Error('性別が入力されていません。');
}
if (user.address === '') {
throw new Error('住所が入力されていません。');
}
const resp = await fetch('https://example.com/api/v1/user', {
method: 'POST',
body: JSON.stringify({
id: Number(user.id),
name: `${user.firstName} ${user.lastName}`,
gender:
user.gender === '0' ? 'male' : user.gender === '1' ? 'female' : 'other',
address: user.address,
}),
})
.then(async (resp) => await resp.json())
.catch((e) => e);
if (resp.status === 200) {
return resp;
} else {
const payl = resp.json();
return {
errorMessage: payl.errorMessage,
};
}
};
モジュール化されたコードの例
次のように書けば34行あった関数が9行にまで減り、見通しが良くなる。
export const registerUser = async (user: User) => {
validateUser(user);
const payload = createRegisterUserPayload(user);
const resp = await requestRegisterUser(payload);
return validateRegisterUserResponse(resp);
};
関数の中で何をしているかわからないという声もあるだろうが、関数名から内容を読み取れるようにしておけば問題にならない。読み取れない処理を入れなければいいのだ。こういう風にべた書きを一定の粒度で関数化することをモジュール化と呼ぶ。
この時、それぞれの関数(validateUser()
など)は単一責務を持つため、単体テストのスコープが明確になる。親となる関数(この場合registerUser()
)のテストでは、子関数をモックすることで簡潔なテストが可能だ。この場合、親となる関数は子関数を呼び出すことが責務になる。
再テストの効率化
コードを直すことはよくあることで、コードを書いているときに一度書いたコードを直したり、レビュー指摘で直したりする。この時に、関係する部分を手動でテストするのは非効率だ。しかし、単体テストがあれば、その工数を大幅に削減できる。
特に分岐の網羅性が重要な場合、単体テストがあるとないとでは開発者の苦痛が大幅に変わるだろう。例えば、4つの分岐があるロジックでさえ、全数網羅では16パターンにも達するため、これを手動で見るのは手間である。しかしテストコードがあれば、自動テストを蹴るだけでいい。心理的負担も時間もこちらの方がローコストで現実的だ。
実装者が設計した時の意図がある程度読み取れる
コードを見ていて「こんなケースはないだろう」と思うことはしばしばあると思うが、そんな時にもしテストコードが存在すればケース名が書いてあるはずだ。それによって当時どのような意図で実装されたのかを知ることができる。これはコードの理解をより深く助ける手助けとなり、「負債に見えたから消したら不具合が出た」といったケースを減らすことに貢献することができる。
手動テストはなくならない
単体テストが充実していても、手動テストが完全になくなるわけではない。特に初回実装時は、プログラムが正しく動作するかどうかを確認する必要があるため、手動テストで動作確認を行い、その上で単体テストを信頼できる状態にするのが基本だ。
単体テストの限界と現実的な運用
さて、ここまで持ち上げてきた単体テストだが、決して銀の弾丸ではない。単体テストを入れてもバグを完全にゼロにすることは出来ないし、正しくないテストコードを書いたり、レビューが不十分であればテストが不正に通過する可能性もある。
正しくないテストコードを書かないための工夫としては、テストコードを書いた後に実装側を修正し、意図的にテストが落ちるようにしたときに、テストが落ちることを確認することが有用だ。これによって、実装が正しくない状態になったときにテストが正しく落ちることが確認できる。逆に落ちなかった場合、そのテストは機能していないと言える。
またテストを全数書くというのも非現実的であるため、TypeScriptの開発であれば型を騙すas
のようなものは使わず、極力正しい型を検証できるように静的解析に任せるのが良い。戻り値の型指定も場合によっては型を騙すのに使えるケースがあるため、バグを生み出す元になる場合もあり、気を付けたほうが良い。
何より単体テストで見れるのは単体であり、結合は見れないため、あくまで単体テストはベースのロジックを担保するものとして使い、全体面は結合テストやe2eテスト、手動テストで見ていくのが良いだろう。
理想的には単体テストで網羅し、正常系と異常系のそれぞれ1パターンを結合で、初回実装時のみ手動テストというのがよいと考えている。これは結合テストで網羅ケースを書いてしまうと単体テストを書く意味がないうえに、重複テストが大量に発生し、実行速度が遅くなるし、規模が大きい分メンテナンスが大変になるからだ。
e2eテストは決済などのクリティカルケースにはあったほうが良いと思うが、コストの重さからしてそれ以外にはあまりなくてよいと思う。大抵のケースで手動の方が楽だろう。手動テストでは結果の誤りが出る可能性は残るが、些末な処理で100点を目指しても仕方がないので、多少の不具合は許容で良いのではないかと思う。
単体テストが有効に機能しづらいケース
単体テストは銀の弾丸ではないため、機能しないケースが当然ある。
元から単体テストがないプロジェクトに導入するケース
元々単体テストが導入されていないプロジェクトでは多くの困難を伴う。
例えば、関数やクラスがべた書きされており、責務が曖昧なまま複雑に絡み合っているコードでは、単体テストを書くこと自体が非常に難しい。分解しようにも目に見えている範囲だけを直せないケースも少なくない。これは他の機能がその機能と強く結合しており、根幹となる部分から直していかないと上手くいかないケースがあるからだ。
この状態の関数に対するテストを書いても、リファクタリングで分割された時の動作保証がやりづらい。何故ならこの手のものは大抵きれいに分割できず、再集計になるまでに紆余曲折をたどるからだ。
単体テストに対する理解が異なるケース
「単体テスト」の「単体の単位」が上手く認識されていない場合、本来あるべき単体テストの意義や役割が意識されていないことがある。これは結合テストやe2eテストが単体テストとして扱われているケースだ。
例えば画面の単体テストの定義が、実際に画面を操作し、APIをコールし、その結果に基づいた動作の確認だったり、複数の関数を呼び出す関数を単体として考えているケースでは、単体に対する意識が異なるため、本来あるべき単体テストの導入が難しいことがある。
有効な開発標準やコーディング規約が存在しないケース
有効な開発標準やコーディング規約がなく、コードの裁量がメンバー個々人に委ねられているケースでは、チームメンバーがそれぞれ独自のスタイルでコードを書いていることもあり、より問題を複雑化させることもある。
こういったケースではありとあらゆるコードが想定外の振る舞いをするため、単体テストの導入を阻む障壁となる。
破壊的変更が多いケース
これは要件や仕様などの上流部分が固まらず、日々変動するようなプロジェクトの話だ。
いつ仕様が固まるかわからないのでコードだけは書き続ける必要があり、日々変わるので動作確認もおろそかになる。このようなプロジェクトでは書いたコードが無価値化することが日常であるため、テストコードを書くという行為自体が陳腐化していきがちだ。
単体テストを書く文化がないケース
プロジェクトの方針として単体テストを書くことを推奨していなかったり、認めていない場所がある。こういった場所では、単体テストの重要性が理解されていないことも少なくなく、テストを書くことがむしろ「余計な作業」として扱われることがある。そういったシーンで単体テストを書くと、プロジェクト内で孤立するリスクがあり、難しい。
まとめ
単体テストを書くことで自然とSOLID的な設計に近づき、コードが読みやすくなり、ある程度変更に強く、開発速度も上がり、一定の品質も担保出来るため、単体テストを書くことによる恩恵は少なくない。これは間違いなくメリットだ。
しかし一方で、単体テストは銀の弾丸ではなく、密結合でテストしにくいコードや、頻繁に仕様変更が発生するプロジェクト、文化的に単体テストが認められていない環境では上手く機能しないケースもある。
- 投稿日:
以下のようなコードを書いたときにmockFnにイベント引数が渡らないが、これをどうにかして取る方法。結論から言うとまともに取れないが、試行錯誤した時のログとして残しておく
const mockFn = jest.fn();
render(<input onChange={mockFn} />);
fireEvent.change(inputElement, { target: { value: 'aaa' } });
確認環境
Env | Ver |
---|---|
@swc/core | 1.3.66 |
@swc/jest | 0.2.26 |
@testing-library/jest-dom | 5.16.5 |
@testing-library/react | 14.0.0 |
jest | 29.5.0 |
react | 18.2.0 |
サンプルコード
これは以下のように書くとfireEvent
の第二引数で指定したtarget.value
の部分だけは一応取れる。
理由としてはfireEvent
がelement.dispatchEvent
を読んでいると思われるためだ。余り深くは追っていないが、react-testing-libraryの実装上は多分そうなっていると思われる。
import { fireEvent, render } from '@testing-library/react';
it('test', () => {
const mockFn = jest.fn((ev) => {
console.log(ev);
});
const { container } = render(<input id="hoge" onChange={mockFn} value={'a'} />);
const element = container.querySelector('input');
if (element === null) throw new Error();
element.dispatchEvent = jest.fn();
fireEvent.change(element, {
target: {
value: 'bbb'
}
});
expect(element.dispatchEvent.mock.instances[0].value).toBe('bbb');
});
- 投稿日:
条件分岐でコンポーネントの出し分けをしている時に、意図したとおりにコンポーネントが出ているかどうかをテストする方法。
UIロジックのリグレッションテストで使え、分岐結果の出力を見てるだけなのでテストとして壊れづらく、運用しやすいのではないかと考えている。
テスト対象の実装コード
JSXが分岐するコード
type BaseProps = {
id: string;
};
type SwitchExampleProps = BaseProps & {
display: 'Foo' | 'Bar';
};
export const Foo = (props: BaseProps) => {
return (
<div id={props.id}>
<p>Foo</p>
</div>
);
};
export const Bar = (props: BaseProps) => {
return (
<div id={props.id}>
<p>Bar</p>
</div>
);
};
export const SwitchExample = (props: SwitchExampleProps) => {
if (props.display === 'Foo') {
return <Foo id={props.id} />;
} else {
return <Bar id={props.id} />;
}
};
2025年版のテストコード
TestRendererが非推奨になったため、その対応。
確認環境
Env | Ver |
---|---|
@swc/core | 1.11.13 |
@swc/jest | 0.2.37 |
jest | 29.7.0 |
next | 15.2.4 |
react | 18.3.1 |
react-dom | 18.3.1 |
react-test-renderer | 未使用 |
typescript | 5.8.2 |
テストコード
コンポーネント呼び出しと、コンポーネントの中身べた書きの二つを書いて、その二つのHTMLが等価であるかどうかを見ている。
本稿ではコールバック関数の中身までは見ていないが、そちらはロジックテストでjest.fn()
を差し込んで確認すればよいと思う。
import { render } from '@testing-library/react';
import { Bar, Foo, SwitchExample } from './SwitchExample';
import { JSX } from 'react';
type TestCase = {
name: string;
param: Parameters<typeof SwitchExample>[0];
actual: JSX.Element;
};
describe('SwitchExample', () => {
const testCaseItems: TestCase[] = [
{
name: 'Foo',
param: {
id: 'hoge',
display: 'Foo'
},
actual: <Foo id={'hoge'} />
},
{
name: 'Bar',
param: {
id: 'piyo',
display: 'Bar'
},
actual: <Bar id={'piyo'} />
}
];
testCaseItems.forEach((item) => {
it(`switched condition ${item.name}`, () => {
const { container: result } = render(
<SwitchExample id={item.param.id} display={item.param.display} />
);
const { container: actual } = render(item.actual);
expect(result.innerHTML).toStrictEqual(actual.innerHTML);
});
});
});
2022年版のテストコード
確認環境
Env | Ver |
---|---|
@swc/core | 1.2.133 |
@swc/jest | 0.2.17 |
jest | 27.4.7 |
next | 12.0.8 |
react | 17.0.2 |
react-dom | 17.0.2 |
react-test-renderer | 17.0.2 |
typescript | 4.5.4 |
テストコード
react-testing-libraryの.toHaveAttribute()
や.toHaveDisplayValue()
を書き連ねるより圧倒的に楽で保守性も良いと思う
import TestRenderer from 'react-test-renderer';
import { Bar, Foo, SwitchExample } from './SwitchExample';
type TestCase = {
name: string;
param: Parameters<typeof SwitchExample>[0];
actual: JSX.Element;
};
describe('SwitchExample', () => {
const testCaseItems: TestCase[] = [
{
name: 'Foo',
param: {
id: 'hoge',
display: 'Foo',
},
actual: <Foo id={'hoge'} />,
},
{
name: 'Bar',
param: {
id: 'piyo',
display: 'Bar',
},
actual: <Bar id={'piyo'} />,
},
];
testCaseItems.forEach((item) => {
// eslint-disable-next-line jest/valid-title
it(`switched condition ${item.name}`, () => {
const result = TestRenderer.create(
<SwitchExample id={item.param.id} display={item.param.display} />
);
const actual = TestRenderer.create(<>{item.actual}</>);
expect(result.toJSON()).toStrictEqual(actual.toJSON());
});
});
});
参考記事
- 投稿日:
確認環境
Env | Ver |
---|---|
next | 11.1.2 |
typescript | 4.3.4 |
サンプルコード
__mocks__/next/router.ts
export const useRouter = () => {
return {
route: '/',
pathname: '',
query: '',
asPath: '',
push: jest.fn(),
replace: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
},
beforePopState: jest.fn(() => null),
prefetch: jest.fn(() => null),
};
};
- 投稿日:
基本出来ないのでwindow.location.href
を操作するためのラッパーを作って、それをjest.spyOn()
して解決する
理由はjsdomが対応していないため