概要
- 初めて本格的な単体テストを行ったので、まとめと振り返りを行う
これまでテストを行わなかった理由
これまでテストを行わなかった理由としては、主に3点である。
-
テストで何をするべきかわからなかった
-
ここの関数やクラスが密結合になっており、単体テストが実質的な結合テストになってしまう構造であったため、単体テストをするために大幅な構造変更が必要でテストを実施するコストが重すぎた
-
2番と共通して、密結合であるためモック化などが使いづらく、テストのモチベーションが下がってしまった
これまでも、幾度かテストを作成していたもののうまくいかないことが多く、テストに対して強い苦手意識を抱いていた。
今回はこれらの理由に対して、どのようなアプローチで解決していったかをまとめつつ継続したテストの作成に必要なことを考えていきたい。
1. テストで何をするべきかどうか
持論であるが初心者にとって一番の難題は、何が正解であるかわからないことであると考えている
実際に、真にテストを初めて作成したタイミングでは、どの程度の粒度で作成するのか、テスト仕様書は必要か、どのメソッド・クラスを優先すべきであるか...etcと大量の疑問があり、進めるに進めることができなかった。
雑にこれらの疑問に対して回答すると
疑問 | 回答 |
---|---|
粒度はどのぐらいか |
|
テスト仕様書は必要か |
|
どこを優先すべきであるか |
|
モックってなに?? |
|
1つの関数をテストしたいが、DBのことを考えないといけない |
|
結局何をすればいいの? |
|
テストを書く意味はあるのか? |
|
どのようなテストを書けばいいか分からない |
|
なので、初心者がすべきこととしては、
-
システムで一番重要な部分を選択
-
パブリックメソッドに対して正常系・閾値チェックを行う
-
この時、構造上単体テストができない場合は気を付けながらテストができる状況にリファクタリングする
となる
最初の頃は特に、質や量が心もとないと感じてもテストを書くことでその勘所をつかむことができるようになる、、、と信じて進めていくことが重要である。
2. テストが書きずらい問題への対処
先ほどから上げているが、特に密結合であるとテストの作成が困難になるため、単なる手続き型から一歩先の作成手法の必要性を実感する機会となるだろう。
そのときに、オブジェクト指向を選択するか、関数型を選択するか、はたまた別のものを選択するかは自由であるが、その本質には疎結合でありながらコードの重複を減らすという考えがある。
テストが書きづらい状況を打開するためのアプローチとして、以下のようなものが挙げられる。
-
関心事の分離を意識する
- 1つのクラスや関数に複数の責務を持たせると、テストが複雑になる
- クラスや関数の役割を明確に分離することで、テストがしやすくなる
-
依存関係を明確にする
- クラス間の依存関係が複雑だと、テストの際にモックの作成が大変になる
- 依存関係を明確にし、依存オブジェクトをコンストラクタや関数の引数で受け取るようにする(依存性の注入)
-
インターフェースを活用する
- インターフェースを用いて実装とテストを分離する
- モックの作成が容易になり、テストの保守性も向上する
-
副作用を減らす
- 副作用のある処理(DBアクセスなど)はテストが難しくなる
- 副作用を持つ処理を分離し、副作用のない純粋な関数を増やす
昨今はDDDに対する風当たりが若干強いものの、何をどうすればより疎結合にしながら重複が減らせるかについてまとめてあるので、参考にしてみるとよいだろう。
少なくとも概念を知らないのと知ったうえで無視することは、相当に大きな開きがある。
テストを書くことは初めのうちは大変かもしれないが、長期的に見れば品質の向上とコストの削減につながる。一歩一歩着実に進めていくことが大切である。
簡単で具体的なテスト例(TS)
以下は外部サービスを模したAPIである、example.com/api/hogeを叩き結果をDBに保存しコンソールに結果を出力する簡単なサンプルコードである
テストの書きずらいアンチパターンコード
import axios from 'axios';
import { createConnection } from 'typeorm';
async function saveApiResult() {
const response = await axios.get('https://example.com/api/hoge');
const data = response.data;
const connection = await createConnection();
const repository = connection.getRepository('ApiResult');
await repository.save(data);
console.log('API result saved:', data);
await connection.close();
}
// 呼び出しイメージ
saveApiResult()
このコードの良くない点
- DBに関する処理が関数内に記載されているため、テストをする際にそのDBを実際に触ってしまう問題がある
- また、APIに関しても同様で、関数内でAPIを直接利用しているのでテスト時に本当に実行してしまう
- さらにコンソールに出力する処理が、呼び出し元にないのでAPIからの結果が想定通りかテストしづらい
- 関数が副作用を持っており、テストが困難
- エラーハンドリングが不十分で、エラー時の動作をテストしづらい
実際にテストコードを作成してみると分かりますが、相当に曲者であります
テストを書きやすく変更したコード
import axios, { AxiosInstance } from 'axios';
import { Connection, Repository } from 'typeorm';
// APIを実際に呼び出すクラス
class ApiClient {
private axiosInstance: AxiosInstance;
constructor(axiosInstance: AxiosInstance) {
this.axiosInstance = axiosInstance; // ここがポイントである
}
async getHoge(): Promise<any> {
const response = await this.axiosInstance.get('/hoge');
return response.data;
}
}
// データベースの保存などに関わるクラス
class DatabaseService {
private connection: Connection;
private repository: Repository<any>;
constructor(connection: Connection) {
this.connection = connection; // ここがポイントである
this.repository = this.connection.getRepository('ApiResult');
}
async save(data: any): Promise<void> {
await this.repository.save(data);
}
}
// データベースに保存だけする関数
async function saveApiResult(
apiClient: ApiClient, // ここがポイントである
databaseService: DatabaseService // ここがポイントである
): Promise<any> {
try {
const data = await apiClient.getHoge();
await databaseService.save(data);
return data;
} catch (error) {
console.error('Error saving API result:', error);
throw error;
}
}
// 呼び出しイメージ
async function main() {
const axiosInstance = axios.create({ baseURL: 'https://example.com/api' });
const apiClient = new ApiClient(axiosInstance);
const connection = await createConnection();
const databaseService = new DatabaseService(connection);
const result = await saveApiResult(apiClient, databaseService);
console.log('API result saved:', result);
}
main();
最初のコードの問題 | 解決方法 |
---|---|
DBに関する処理が関数内に記載されているため、テストをする際にそのDBを実際に触ってしまう問題がある | DatabaseServiceクラスを作成し、DBに関する処理を分離した。テスト時にはモックを使用できる。 |
APIに関しても同様で、関数内でAPIを直接利用しているのでテスト時に本当に実行してしまう | ApiClientクラスを作成し、APIの呼び出しを分離した。テスト時にはモックを使用できる。 |
コンソールに出力する処理が、呼び出し元にないのでAPIからの結果が想定通りかテストしづらい |
saveApiResult 関数の戻り値として、APIの結果を返すようにした。呼び出し元で結果を確認できる。 |
関数が副作用を持っており、テストが困難 | APIの呼び出しとDBへの保存を別のクラスに分離し、副作用を持つ処理を隔離した。 |
エラーハンドリングが不十分で、エラー時の動作をテストしづらい |
saveApiResult 関数内でエラーハンドリングを行い、エラー時の動作をテストできるようにした。 |
コードの記述量が増えたものの、それぞれが個別に独立したのが分かる。
対応する単体テスト
import axios from 'axios';
import { ApiClient } from './api-service';
describe('ApiClient', () => {
let apiClient: ApiClient;
beforeEach(() => {
const axiosInstance = axios.create({ baseURL: 'https://example.com/api' });
apiClient = new ApiClient(axiosInstance);
});
it('should fetch data from API', async () => {
const apiResult = { id: 1, name: 'John' };
jest.spyOn(axios, 'get').mockResolvedValue({ data: apiResult });
const result = await apiClient.getHoge();
expect(axios.get).toHaveBeenCalledWith('/hoge');
expect(result).toEqual(apiResult);
});
it('should throw an error when API call fails', async () => {
const errorMessage = 'API error';
jest.spyOn(axios, 'get').mockRejectedValue(new Error(errorMessage));
await expect(apiClient.getHoge()).rejects.toThrow(errorMessage);
});
});
以下のテストを行っている:
-
axios.get
をモック化し、APIからのレスポンスをシミュレートする -
getHoge
メソッドが正しくデータを取得することを検証する - APIコールが失敗した場合に、エラーが投げられることを検証する
import { mock } from 'jest-mock-extended';
import { Connection, Repository } from 'typeorm';
import { DatabaseService } from './api-service';
describe('DatabaseService', () => {
let databaseService: DatabaseService;
let repository: Repository<any>;
beforeEach(() => {
const connection = mock<Connection>();
repository = mock<Repository<any>>();
connection.getRepository.mockReturnValue(repository);
databaseService = new DatabaseService(connection);
});
it('should save data to database', async () => {
const data = { id: 1, name: 'John' };
await databaseService.save(data);
expect(repository.save).toHaveBeenCalledWith(data);
});
});
以下のことをテストしている:
DatabaseServiceの単体テスト:
-
Connection
とRepository
をモック化し、データベースへの保存をシミュレートする -
save
メソッドが正しくデータを保存することを検証する
これらの単体テストにより、ApiClientとDatabaseServiceがそれぞれ期待通りに動作することを確認でき、各クラスの責務が正しく果たされていることを検証できる
対応する結合テスト
import axios from 'axios';
import { mock } from 'jest-mock-extended';
import { Connection, Repository } from 'typeorm';
import { ApiClient, DatabaseService } from './api-service';
describe('saveApiResult', () => {
let apiClient: ApiClient;
let databaseService: DatabaseService;
beforeEach(() => {
const axiosInstance = axios.create({ baseURL: 'https://example.com/api' });
apiClient = new ApiClient(axiosInstance);
const connection = mock<Connection>();
const repository = mock<Repository<any>>();
connection.getRepository.mockReturnValue(repository);
databaseService = new DatabaseService(connection);
});
it('should save API result to database', async () => {
const apiResult = { id: 1, name: 'John' };
jest.spyOn(apiClient, 'getHoge').mockResolvedValue(apiResult);
const result = await saveApiResult(apiClient, databaseService);
expect(apiClient.getHoge).toHaveBeenCalled();
expect(databaseService.save).toHaveBeenCalledWith(apiResult);
expect(result).toEqual(apiResult);
});
it('should throw an error when API call fails', async () => {
const errorMessage = 'API error';
jest.spyOn(apiClient, 'getHoge').mockRejectedValue(new Error(errorMessage));
await expect(saveApiResult(apiClient, databaseService)).rejects.toThrow(
errorMessage
);
});
});
テストコードでは、以下の点のテストを行っている:
-
jest-mock-extended
を使用して、Connection
とRepository
のモックを作成 -
jest.spyOn
を使用して、APIクライアントとデータベースサービスのメソッドをモック化し、呼び出しを検証 - 正常系と異常系のテストを作成し、関数の動作を網羅的にテスト
テストコードを見ると分かるように、呼び出しイメージと同じことをしているがClientやインスタンスを別のもの与えることで、テスト用のインスタンスなどに切り替えている
このような書き方をすると、引数で与えるものを切り替えることで同じコードで別のDBなどに登録することも可能になる
テストが書きやすいコードとは結局のところ、汎用性のある良いコードになりやすいのである
まとめ
初めての単体テストを行ったときに、感じた疑問などをちょっとテストやってみた人間が振り返りながらまとめてみた
実際にはもっと考えることも多くあると思うが、これからも少しづつ勉強しようと思う