個人的興味からテストスメルについて調べているので、少しずつブログにもまとめておきます。

テストスメルとは

テストコードに潜む潜在的な問題を示す兆候、あるいはテストコードの品質や保守性に悪影響を及ぼす可能性のある、テストコードの設計や実装における欠陥を指します。

特にユニットテストを指していることが多いようで、アンチパターンとほぼ同義のように見えます。

テストスメルの分類

The Open Catalog of Test Smells — Test Smells Catalog 2.0 documentationによると以下のカテゴリで分類されています。

  • コードに関するもの(Code related)
  • 依存関係(Dependencies)
  • 設計に関するもの(Design related)
  • テスト手順の問題(Issues in test step)
  • テスト実行 - ふるまい
  • テスト意味論 - ロジック

上記のサイトでは各カテゴリに対してテストスメルが記載されていますが、量がかなり多いです。

https://testsmells.org におけるテストスメル一覧

The Open Catalog of Test Smells、はあまりに多いので、Test Smell Typesに記載の項目を翻訳してみました。(Geminiが)

アサーションルーレット (Assertion Roulette)

概要: テストメソッドが、文書化されていない複数のアサーションを持つ場合に発生します。記述的なメッセージがないテストメソッド内で複数のアサーション文を使用すると、テスト失敗の理由を理解することが困難になるため、可読性、理解性、保守性が損なわれます。

検出方法: テストメソッドが、説明やメッセージ(アサーションメソッドのパラメータ)なしに、複数のアサーション文を含んでいること。

条件付きテストロジック (Conditional Test Logic)

概要: テストメソッドはシンプルである必要があり、製品コードのメソッド内のすべてのステートメントを実行する必要があります。テストメソッド内の条件分岐は、テストの動作と期待される出力を変更し、条件が満たされなかったためにテストステートメントが実行されず、製品コードのメソッド内の欠陥をテストが検出できない状況につながる可能性があります。さらに、テストメソッド内の条件付きコードは、開発者による理解のしやすさに悪影響を与えます。

検出方法: テストメソッドが、1つ以上の制御文(例:if、switch、条件式、for、foreach、while文)を含んでいること。

コンストラクタ初期化 (Constructor Initialization)

概要: 理想的には、テストスイートはコンストラクタを持つべきではありません。フィールドの初期化は、setUp() メソッドで行うべきです。 setUp() メソッドの目的を認識していない開発者は、テストスイートにコンストラクタを定義することで、このスメルを引き起こす可能性があります。

検出方法: テストクラスがコンストラクタ宣言を含んでいること。

デフォルトテスト (Default Test)

概要: デフォルトでは、Android Studioはプロジェクト作成時にデフォルトのテストクラスを作成します。これらのクラスは、開発者がユニットテストを作成する際の例として提供されることを意図しており、削除または名前を変更する必要があります。プロジェクト内にこのようなファイルがあると、開発者はこれらのファイルにテストメソッドを追加し始め、デフォルトのテストクラスがすべてのテストケースのコンテナになる可能性があります。これは、将来クラスの名前を変更する必要がある場合に問題を引き起こす可能性もあります。

検出方法: テストクラスの名前が ExampleUnitTest または ExampleInstrumentedTest のいずれかであること。

重複アサート (Duplicate Assert)

概要: このスメルは、テストメソッドが同じテストメソッド内で同じ条件を複数回テストする場合に発生します。テストメソッドが異なる値を使用して同じ条件をテストする必要がある場合は、新しいテストメソッドを使用する必要があります。テストメソッドの名前は、実行されているテストを示すものであるべきです。このスメルが発生する可能性のある状況には、(1) 開発者が単一のメソッドをテストするために複数の条件をグループ化している、(2) 開発者がデバッグ作業を実行している、(3) コードの誤ったコピー&ペースト、などが含まれます。

検出方法: テストメソッドが、同じパラメータを持つ複数のアサーション文を含んでいること。

欲張りなテスト (Eager Test)

概要: テストメソッドが、製品オブジェクトの複数のメソッドを呼び出す場合に発生します。このスメルは、テストの理解と保守を困難にします。

検出方法: テストメソッドが、複数の製品コードのメソッドへの呼び出しを含んでいること。

空のテスト (Empty Test)

概要: テストメソッドが、実行可能なステートメントを含んでいない場合に発生します。このようなメソッドは、デバッグ目的で作成され、その後忘れられたり、コメントアウトされたコードが含まれている可能性があります。空のテストは、テストケースが全くないよりも問題があり、危険であると考えられます。なぜなら、JUnitはメソッド本体に実行可能なステートメントが存在しなくても、テストが成功したと表示するからです。そのため、製品コードのクラスに動作を破壊する変更を導入する開発者は、JUnitがテストを成功として報告するため、変更された結果に気づかないでしょう。

検出方法: テストメソッドが、1つの実行可能なステートメントも含まないこと。

例外処理 (Exception Handling)

概要: このスメルは、テストメソッドの成功または失敗が、製品コードのメソッドが例外をスローするかどうかに依存する場合に発生します。開発者は、カスタムの例外処理コードを作成したり、例外をスローしたりする代わりに、JUnitの例外処理を利用してテストを自動的に成功/失敗させるべきです。

検出方法: テストメソッドが、throw ステートメントまたは catch 句のいずれかを含んでいること。

汎用的なフィクスチャ (General Fixture)

概要: テストケースのフィクスチャが汎用的すぎ、テストメソッドがその一部にしかアクセスしない場合に発生します。テストのセットアップ/フィクスチャメソッドが、テストメソッドによってアクセスされないフィールドを初期化している場合、フィクスチャが汎用的すぎることが示唆されます。汎用的すぎることの欠点は、テストメソッドが実行されるときに不要な作業が行われることです。

検出方法: テストクラスの setUp メソッド内でインスタンス化されたすべてのフィールドが、同じテストクラス内のすべてのテストメソッドによって利用されているわけではないこと。

無視されたテスト (Ignored Test)

概要: JUnit 4 は、テストメソッドの実行を抑制する機能を開発者に提供します。しかし、これらの無視されたテストメソッドは、コンパイル時間に関して不要なオーバーヘッドを追加し、コードの複雑さと理解度を高めるため、オーバーヘッドをもたらします。

検出方法: @Ignore アノテーションを含むテストメソッドまたはクラス。

怠惰なテスト (Lazy Test)

概要: 複数のテストメソッドが、製品オブジェクトの同じメソッドを呼び出す場合に発生します。

検出方法: 複数のテストメソッドが、同じ製品コードのメソッドを呼び出していること。

マジックナンバーテスト (Magic Number Test)

概要: テストメソッドのアサート文に、パラメータとして数値リテラル(マジックナンバー)が含まれている場合に発生します。マジックナンバーは、数値の意味/目的を示していません。したがって、定数または変数に置き換え、入力に対して記述的な名前を提供する必要があります。

検出方法: アサーションメソッドが、引数として数値リテラルを含んでいること。

謎のゲスト (Mystery Guest)

概要: テストメソッドが、外部リソース(ファイル、データベースなど)を利用する場合に発生します。テストメソッドで外部リソースを使用すると、安定性とパフォーマンスの問題が発生します。開発者は、外部リソースの代わりにモックオブジェクトを使用する必要があります。

検出方法: ファイルクラスやデータベースクラスのオブジェクトインスタンスを含むテストメソッド。

冗長なプリント (Redundant Print)

概要: ユニットテスト内のプリントステートメントは、ユニットテストがほとんどまたは全く人間の介入なしに自動化されたプロセスの一部として実行されるため、冗長です。プリントステートメントは、トレーサビリティとデバッグの目的で開発者によって使用され、その後忘れられる可能性があります。

検出方法: System クラスの printprintlnprintf、または write メソッドのいずれかを呼び出すテストメソッド。

冗長なアサーション (Redundant Assertion)

概要: このスメルは、テストメソッドに常に真または常に偽になるアサーション文が含まれている場合に発生します。このスメルは、デバッグ目的で開発者によって導入され、その後忘れられます。

検出方法: 期待値パラメータと実際値パラメータが同じアサーション文をテストメソッドが含んでいること。

リソース楽観主義 (Resource Optimism)

概要: このスメルは、テストメソッドが、テストメソッドで使用される外部リソース(例:ファイル)が存在するという楽観的な仮定をしている場合に発生します。

検出方法: テストメソッドが、File クラスのインスタンスを、オブジェクトの exists()isFile()、または notExists() メソッドを呼び出さずに使用していること。

繊細な等価性 (Sensitive Equality)

概要: toString メソッドがテストメソッド内で使用されている場合に発生します。テストメソッドは、オブジェクトのデフォルトの toString() メソッドを呼び出し、その出力を特定の文字列と比較することでオブジェクトを検証します。 toString() の実装を変更すると、失敗する可能性があります。正しいアプローチは、この比較を実行するためにオブジェクト内にカスタムメソッドを実装することです。

検出方法: テストメソッドが、オブジェクトの toString() メソッドを呼び出すこと。

眠たいテスト (Sleepy Test)

概要: スレッドを明示的にスリープさせると、タスクの処理時間がデバイスによって異なるため、予期しない結果につながる可能性があります。開発者は、テストメソッドでステートメントの実行を一定時間一時停止する必要がある場合(外部イベントをシミュレートするなど)、その後実行を継続する場合に、このスメルを導入します。

検出方法: Thread.sleep() メソッドを呼び出すテストメソッド。

不明なテスト (Unknown Test)

概要: アサーション文は、テストメソッドの期待されるブール条件を宣言するために使用されます。アサーション文を調べることで、テストメソッドの目的を理解することができます。ただし、アサーション文なしでテストメソッドが記述される可能性があり、そのような場合、JUnitはテストメソッド内のステートメントが実行時に例外を引き起こさなかった場合、テストメソッドを成功として表示します。プロジェクトの新しい開発者は、そのようなテストメソッドの目的を理解するのが困難になります(特にテストメソッドの名前が十分に記述的でない場合はなおさらです)。

検出方法: アサーション文と @Test(expected) アノテーションパラメータのいずれも含まないテストメソッド。

テストスメルの悪影響

上記の各種テストスメルに該当するようなテストがあった場合、以下のような影響が考えられます。

  • テストコードの可読性低下
  • テストコードの保守性低下
  • テストの信頼性低下
  • 実行時間の増加

テストスメルを検知・改善するには

たとえば静的解析やコードレビュー、ミューテーションテストなどで改善できます。

そもそものテストスメルの概念と各パターンをざっくりとでも把握しておくところがスタートかとも思います。

個人的興味:E2Eテストやシステムテストにおけるテストスメルや検知・対応方法

これらのテストスメルは、特にテストレベルを限定しないこともあれば、単体テストが暗黙の前提になっているものもあるようです。個人的にはE2Eテストにおけるテストスメルをまとめたい気持ちでいます。既存のテストスメルと共通するものもあれば、E2Eテスト固有のものもあるのでは?と感じていて、たとえば自動テストツール側がテストスメルを検知して修正提案してくれると嬉しいですよね。(書いてくれたら一番うれしいですけど)