序論
テストコードに初めて触れるときや、テストコードを書こうとするときに、私の場合、2つの疑問が生まれました。
- どうしてテストを行わなければならないのか?
- 何をテストするべきなのか?
なぜテストが必要なのか?
コードの信頼性を確保するために
- 品質保証
- デバッグコストの削減
- ドキュメント化、ドメイン駆動開発、アジャイル方法論、エクストリームプログラミングの実現など ..
しかし、私にはそれほどピンと来ませんでした。そこで、これらを簡単に整理してみました
XPエコシステムから生まれたアジャイル開発論を採用したドメイン駆動開発の観点から説明します。
ソフトウェア開発において最も重要な要素は「ユーザー」です。そして、ユーザーのニーズは常に変化します。プロジェクトを開始する時点では、要件が不透明であることが多いため、私たちのコードはその変化に柔軟に対応できる必要があります。
要件が変わると、私たちは「リファクタリング」を行い、コードを新しいドメインに適合させていきます。
私たちのコードは、手を離れた瞬間にレガシーとなり、その時点でそのコードに対する信頼を失ってしまいます。
後でリファクタリングを行う際、そのコードやメソッドがどのように動作するかを正確に把握している人はほとんどいません。
そこで、自分たちがコードに対する信頼を持てるようにするため、XPではさまざまな方法を導入しています。具体的には、ペアプログラミング、小さな単位でのリリース、シンプルな設計、TDD(テスト駆動開発)、継続的インテグレーション、顧客テストなどがあります。
JavaScriptの代わりにTypeScriptを使う理由や、多くのソフトウェア会社が開発環境を構築する理由がここにあります。これらはすべて、
変更に柔軟に対応し、コードに対する信頼を確保するための努力です
それなら答えが出ましたね。テストコードは、変更に柔軟に対応するために導入された方法論です。
質問 1
それでは、常にテストコードを書くことが有用なのでしょうか?
回答
そうではありません。代表的な例として、学校の宿題やアルゴリズムの問題を解く場合、または自販機のプログラムを作成する場合など、変更が少ないコードを書くときには、テストコードを作成することは有用ではないかもしれません。
後で説明しますが、テストコードには作成コストというものがあるからです。
質問 2
私たちのチームのプログラムは順調に動作しています。でも、テストコードを導入しようとすると時間がかかりすぎるようです。導入すべきでしょうか?
回答
そうではありません。すべてのテストは、私たちの信頼性を確保するための手段です。テストコードのカバレッジが100パーセントであっても、コードが正常に動作しないリスクがあるように、テストコードは絶対的な万能薬ではなく、単なる一つの手段に過ぎません。あなたのチームのプログラムが順調に動作しているということは、多くの実際のユーザーがそのコードを検証していることを意味します。これを基に、ある程度のコードの信頼性を確保できるでしょう。
テストは無料ではありません。
テストは万能ではありません。厳密に言えば、テストもコードの一部であり、それを書くためには時間を投資しなければなりません。
テスト作成時に発生するコストを大きく2つに分けることができます。
- 作成コスト、保守コスト
トレードオフとして投資することによって得られるのは、"拡張性"です(変更に柔軟に対応できるようになります)。
作成費用とは?
現在検証しようとしているメソッドがどのようなリクエストを必要としているか。
保守費用とは?
リクエストを受けたメソッドやロジックがどのような応答を返すべきか。
開発者は効率的な選択をしなければなりません。
作成コスト+保守コスト<拡張性
となるようにするべきです。
さあ、答えが出ました。コストを削減しましょう。
何をテストするべきなのか?
作成費用が大きくなる理由は、あまりにも多くのことを検証しようとするからです。
OOPを学んだ方ならご存知のように、すべてのオブジェクトはそれぞれの責任を負わなければなりません。
コードで例を挙げてみます。
入る前に、要件を分析する必要があります。
- スマートフォンは電源を入れることができる。
- スマートフォンは電源を切ることができる。
public class SmartPhone {
private boolean power;
public SmartPhone() {
this.power = false;
}
public void turnOn() {
power = true;
}
public void turnOff() {
power = false;
}
}
いいですね。それなら追加の要件分析が必要です。
これから定義するのは、要件のルールです。
- バッテリーが0パーセントの場合、電源を入れることができない
- すでに電源が切れている場合、再び切ることはできない
- すでに電源が入っている場合、再び入れることはできない
これは変わらない特性、つまり不変式(インバリアント)と呼ばれます。
public class SmartPhone {
private boolean power;
private int batteryPercentage;
public SmartPhone(boolean power, int batteryPercentage) {
this.power = power;
this.batteryPercentage = batteryPercentage;
}
public void turnOn() {
if (batteryPercentage > 0) {
if (!isPower()) {
power = true;
return;
}
throw new RuntimeException("スマートフォンはすでに電源が入っています。");
}
throw new RuntimeException("バッテリーがありません。");
}
public void turnOff() {
if (isPower()) {
power = false;
return;
}
throw new RuntimeException("スマートフォンはすでに電源が切れています。");
}
public boolean isPower() {
return power;
}
}
感がだんだんつかめてきましたか?
テストする要素は簡単です。
私たちの不変式を検証すればいいのです。
テスト = 不変式(要件のルール)検証
class SmartPhoneTest {
@Test
public void 電源がオフでバッテリーがある場合電源をオンにできる() {
// given
SmartPhone phone = new SmartPhone(false, 50);
// when
phone.turnOn();
// then
assertTrue(phone.isPower());
}
@Test
public void 電源がオフでバッテリーがない場合電源をオンにできない() {
// given
SmartPhone phone = new SmartPhone(false, 0);
// when & then
assertThrows(RuntimeException.class, phone::turnOn);
assertFalse(phone.isPower());
}
@Test
public void 電源がオンの場合再度電源をオンにできない() {
// given
SmartPhone phone = new SmartPhone(true, 50);
// when & then
assertThrows(RuntimeException.class, phone::turnOn);
assertTrue(phone.isPower());
}
@Test
public void 電源がオンの場合電源をオフにできる() {
// given
SmartPhone phone = new SmartPhone(true, 50);
// when
phone.turnOff();
// then
assertFalse(phone.isPower());
}
@Test
public void 電源がオフの場合電源をオフにできない() {
// given
SmartPhone phone = new SmartPhone(false, 50);
// when & tenn
assertThrows(RuntimeException.class, phone::turnOff);
assertFalse(phone.isPower());
}
}
終わりました。
これを私たちはテストコードで不変式を文書化することと言います。
テストコードを見ることで内部の動作を推測できるようにします。
内部のif文や処理はprivateメソッドに分けて整理するのが良いでしょう。
今日はテストについての例を示すためにこうしました。^-^
何をテストしますか?
要件の不変式を検証します。
作成費用の削減, 保守費用の削減
時には、あまりにも多くのことを1つのテストコードで検証しようとすることがあります。
例えば、テストに必ずしも必要ではないのにリストに2つ以上の要素を追加したり、検証ロジックが多かったりします。
@Test
void testAddItem() {
ShoppingCart cart = new ShoppingCart();
List<Item> items = new ArrayList<>();
// テストに必要以上のアイテムを追加
items.add(new Item("リンゴ", 1000));
items.add(new Item("バナナ", 1500));
items.add(new Item("オレンジ", 2000));
for (Item item : items) {
cart.addItem(item);
}
// 過度の検証
assertEquals(3, cart.getItemCount());
assertEquals(4500, cart.getTotalPrice());
assertTrue(cart.containsItem("リンゴ"));
assertTrue(cart.containsItem("バナナ"));
assertTrue(cart.containsItem("オレンジ"));
assertFalse(cart.containsItem("ブドウ"));
assertEquals(1000, cart.getItemPrice("リンゴ"));
assertEquals(1500, cart.getItemPrice("バナナ"));
assertEquals(2000, cart.getItemPrice("オレンジ"));
assertThrows(IllegalArgumentException.class, () -> cart.getItemPrice("ブドウ"));
cart.removeItem("バナナ");
assertEquals(2, cart.getItemCount());
assertEquals(3000, cart.getTotalPrice());
assertFalse(cart.containsItem("バナナ"));
cart.clear();
assertEquals(0, cart.getItemCount());
assertEquals(0, cart.getTotalPrice());
}
}
このようにテストコードを書くといくつかの問題が発生します。
テストコードは一種の文書化です。何をテストしているのか明確にする必要があります。
SOLID原則の中のSRP(単一責任の原則)を見ると、1つのオブジェクトは1つの責任を持つべきです。
上記のコードをどう改善すべきでしょうか?
テストコードは一種の文書化です。
- testAddItem (アイテムを追加できる) というテストコードの目的を明確にする
- 結果は目的(不変式を検証します)(ここではカートにアイテムを追加できる)
@Test
void testAddItem() {
// given テスト設定: 空のカートを準備
ShoppingCart cart = new ShoppingCart();
Item apple = new Item("リンゴ", 1000);
// when テスト実行: アイテムを追加
cart.addItem(apple);
// then テスト検証: カートにアイテムが追加されたことを確認
assertEquals(1, cart.getItemCount());
いかがですか?
このカートに入っている商品の価格がいくらなのか知る必要はありません。
その商品の名前が何か知る必要もありません。
テストの目的はカートにアイテムを追加することです.
このテストコードを直接実行しなくても、結果を推測することができます。
例えば、「ああ、カートには果物を追加できるんだ!」という感じです。
整理
作成費用を削減する方法 : 最小限のオブジェクトで生成すること!
保守費用を削減する方法 : 1つのテストコードには1つの不変式のみを検証する。
その他のテストのヒント
- Serviceレイヤーは基本的に手続き指向のコードです。普通、Serviceレイヤーには「意図」だけが存在し、「状態」は存在しません。だから、Serviceレイヤーはモッキングを通じてフローをテストするのが便利でした
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Member createGuest() {
Member member = Member.createByNickName(GuestNickNameBuilder.buildNickName());
memberRepository.save(member);
return member;
}
}
public class GuestNickNameBuilder {
private static final String GUEST_DEFAULT = "guest_";
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final int NICKNAME_LENGTH = 8;
private static final SecureRandom RANDOM = new SecureRandom();
public static String buildNickName() {
StringBuilder nickname = new StringBuilder(GUEST_DEFAULT);
for (int i = 0; i < NICKNAME_LENGTH; i++) {
int index = RANDOM.nextInt(CHARACTERS.length());
nickname.append(CHARACTERS.charAt(index));
}
return nickname.toString();
}
}
このようなロジックがあるとき
@Test
@DisplayName("ゲストアカウント作成テスト")
public void ゲストアカウント作成テスト() {
// given
Member testMember = Member.createByNickName("Guest123");
try (MockedStatic<GuestNickNameBuilder> mockedStatic = mockStatic(GuestNickNameBuilder.class)) {
mockedStatic.when(GuestNickNameBuilder::buildNickName).thenReturn("Guest123");
when(memberRepository.save(any(Member.class))).thenReturn(testMember);
// when
Member result = memberService.createGuest();
// then
verify(memberRepository, times(1)).save(any(Member.class));
assertEquals("Guest123", result.getNickName());
}
}
- このように内部の実装よりもメソッドの意図を検証します
- テストが簡単なコードは良いコードと言えるでしょう。もしテストを作成する際に、作成コストや保守コストが高すぎる場合は、オブジェクトの協力関係を再考する必要があります
なぜなら、1つのメソッドには1つの行為だけが存在するからです。
- テストカバレッジは絶対的な指標ではありません。テストカバレッジが100%であっても問題のないコードであると保証することはできません。常にテストを通じて、自分自身とコードに対する信頼度を高める道具であると考えるべきです
終わりに
本来はUnit Test(単体テスト)、Integration Test(統合テスト)、E2E Test(エンドポイントからエンドポイントまでのテスト)についても詳しく扱おうと思っていましたが、文章を書くのはとても大変ですね。
漢字をいくつか覚えていきます。
ChatGPTとPapagoは最高ですね。
Google翻訳はあまり良くないですね…。
私は韓国人として、日本語を勉強するためにChatGPTやPapago、Google翻訳を活用してこの文章を書きました。
文脈が不自然だったり、おかしな部分が多いかもしれません。
ご指摘いただければ学んでいきます。
技術的なフィードバックもいつでも歓迎します。
ありがとうございます。よい一日をお過ごしください。
まだ日本語の入門者なので、一生懸命勉強します。
私の意図通りに翻訳機が翻訳しないことは私も理解していますが、日本語の実力が上がるにつれて修正していきます。
いずれにしても、この内容はあくまで私個人の考えであることを改めてお知らせします。
レファレンス
stevensanderson Selective Unit Testing – Costs and Benefits
ソウルスプリングキャンプ2024「私はなぜテストを書くのが嫌いなのか」受講
その他の個人的な経験とテストコードの書籍