Help us understand the problem. What is going on with this article?

担当したPJの単体テストが地獄絵図だったので独自に作成したガイド(?)を公開する

はじめに

こんにちは, ねげろんです。

2020年上期にとあるJavaの開発案件にアサインされたのですが, 自分含めメンバが作成したUTコードが カオスここに極まれり でした。

例えば,
・JUnit4とJUnit5のコードが混在しているテスト
・何もassertせずにただメソッドを呼んでいるだけのテスト
・成功するときと失敗するときがある再現性がないテスト
・ステータスコードが200だろうが404だろうが500だろうが成功してしまうモックを使った正常系テスト
などなど・・・。

本PJは追加機能実装のため下期も開発を継続するのですがこのままではまずいと思い,
先輩社員の方に相談のもと単体テストのガイド(?)を作ることにしました。

本記事ではそのガイドを公開します。

ただし, 上述したような 混沌とした現場 のためのガイドであるということをご承知おきください。

また筆者自身も単体テストに精通しているわけではありません。
もし「もっとこうしたほうがいいよ!」や「それは間違っている!」みたいな意見がございましたら是非コメントに書いていただければと思います!

本記事が「単体テストってどう進めればいいの?」と悩んでいる方の助けになれば幸いです!

以下, JUnit5を想定しての記述です。

基本

テストケース

  • 一つのテストケースにあまり多くのテストを含めず簡潔にする
    • NGになった際の解析が面倒になってしまう
  • テストケース内は4フェーズでテストする
    • 【事前準備】 → 【実行】 → 【検証】 → 【後処理】の4フェーズでテストを記載する
Add.java
// テスト対象クラス
public class Calc {

    public int add(int a, int b) {
        return a + b;
    }

}
CalcTest.java
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

// テストクラス
class CalcTest {

    @Test
    void addメソッドに2と3を渡すと5を返す() {
        // 【事前準備】
        Calc sut = new Calc();
        int expected = 5;

        // 【実行】
        int actual = sut.add(2, 3);

        // 【検証】
        assertEquals(expected, actual);

        // 【後処理】
        /*
         * インスタンスの破棄やファイルのcloseなど
         * 必要があれば処理を書く
         */
    }

}
  • 【実行】フェーズでは 評価対象のメソッドもしくはコンストラクタを1つだけ呼ぶ
    • 横断的な評価をする場合は別だが, 単体テストではそもそも横断的なテストはあまり行わない
  • 【検証】フェーズでは assertEquals(期待値, 検査対象) という構文で評価を行う
    • 他のassertionに assertTrue/False , assertNull/NotNull, assertSame/NotSame などがあるので必要に応じて使い分ける
    • JUnit4では assertThat(検査対象, matcher関数(期待値)) という構文で評価をしていたが, asserThatとMatcher関数はJUnit5では廃止された
    • 該当ライブラリを読み込めば使用することは可能
  • 例外がthrowされていることのテストには assertThrows を用いる
Bookshelf.java
// テスト対象クラス
public class Bookshelf {

    String[] books = new String[3];
    public String addBook(int i, String title) {
        books[i] = title;
        return title;
    }

    public List<String> readBook(String str) throws IOException {
        Path path = Paths.get(str);
        List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
        return lines;
    }

}
BookshelfTest.java
import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;

import java.io.IOException;

// テストクラス
class BookshelfTest {

    @Test
    void インデックスに3以上を指定してaddBookメソッドを呼ぶとArrayIndexOutOfBoundsExceptionをthrowする() {
        // 【事前準備】
        Bookshelf sut = new Bookshelf();

        // 【実行】
        assertThrows(ArrayIndexOutOfBoundsException.class, () -> sut.addBook(3, "JavaTextBook"));
    }

    @Test
    void 存在しないファイルを指定してreadBookメソッドを呼ぶとIOExceptionをthrowする() {
        // 【事前準備】
        Bookshelf sut = new Bookshelf();

        // 【実行】
        assertThrows(IOException.class, () -> sut.readBook("hoge.txt"));
    }

}

命名規則について

  • テストクラス名はテスト対象クラス名の末尾に「Test」をつけて命名する
    • 例えばConfigクラスのテストクラス名は「ConfigTest」とする
  • テストメソッド(=テストケース)は 日本語でテストの内容を簡潔に書く
    • 例) addメソッドに2と3を渡したら5を返す ( )
    • 足し算( ), 足し算ケース1 ( ), 足し算ケース2( ), …のような何を評価しているか分からないようなテスト名は避けること
    • JUnit5では @DisplayName アノテーションでテストの表示名を設定できる
    • メソッド名には「数値から始めることはできない」等の制約があるのでこのアノテーションを付与したほうが便利なこともある
  • 評価対象クラスのオブジェクトの変数名は sut (System Under Test) とする
  • 実行結果の変数名は actualactualXXX , 期待値の変数名は expectedexpectedXXX など, それぞれ実行結果と期待値であることがわかるようにする
    • 必ずしも変数に入れる必要はない
    • assertEquals(0, actual); のような即値のほうが分かり易いものは即値でよい

JUnit5の推奨機能

テストの構造化

  • テストの構造化を行えば, 各テストケースを前処理や目的によってグループ化することができる
    • 内部クラスに @Nested アノテーションを付与することで階層化を実現する
    • @BeforeEach@AfterEach アノテーションを付与すれば各テストを実行する前の事前処理や後処理を書くこともできる
    • 階層ごとに選択してテストを実行することも可能
BookshelfTest.java
import static org.junit.jupiter.api.Assertions.*;

import java.io.IOException;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

class BookshelfTest {

    @Nested
    class 本を本棚に3冊まで格納できる {
        private Bookshelf sut;
        private String expected1 = "JavaText";
        private String expected2 = "PythonText";
        private String expected3 = "RubyText";

        @BeforeEach
        void setUp() {
            sut = new Bookshelf();
        }

        @Test
        void addBookメソッドに本を1冊追加できる () {
            // 【事前準備】

            // 【実行】
            String actual = sut.addBook(0, expected1);

            // 【検証】
            assertEquals(expected1, actual);
        }

        @Test
        void addBookメソッドに本を2冊追加できる () {
            // 【事前準備】

            // 【実行】
            String actual1 = sut.addBook(0, expected1);
            String actual2 = sut.addBook(1, expected2);

            // 【検証】
            assertEquals(expected1, actual1);
            assertEquals(expected2, actual2);
        }

        @Test
        void addBookメソッドに本を3冊追加できる () {
            // 【事前準備】

            // 【実行】
            String actual1 = sut.addBook(0, expected1);
            String actual2 = sut.addBook(1, expected2);
            String actual3 = sut.addBook(1, expected3);

            // 【検証】
            assertEquals(expected1, actual1);
            assertEquals(expected2, actual2);
            assertEquals(expected3, actual3);
        }
    }

    @Nested
    class 異常系のテスト {
        private Bookshelf sut;

        @BeforeEach
        void setUp() {
            sut = new Bookshelf();
        }

        @Test
        void インデックスに3以上を指定してaddBookメソッドを呼ぶとArrayIndexOutOfBoundsExceptionをthrowする() {
            // 【事前準備】
            Bookshelf sut = new Bookshelf();

            // 【実行】
            assertThrows(ArrayIndexOutOfBoundsException.class, () -> sut.addBook(3, "JavaTextBook"));
        }

        @Test
        void 存在しないファイルを指定してreadBookメソッドを呼ぶとIOExceptionをthrowする() {
            // 【事前準備】
            Bookshelf sut = new Bookshelf();

            // 【実行】
            assertThrows(IOException.class, () -> sut.readBook("hoge.txt"));
        }
    }

}

パラメータ化テスト

  • パラメータ化テストを用いれば, 異なる引数で複数回実行できるようになる
    • パラメータ化テストでは @Test ではなく @ParameterizedTest アノテーションを付与する
    • 引数のsourceもアノテーションを付与することで指定する
    • いくつかの種類があるがここでは @CsvSource アノテーションを例示する
    • 外部のcsvファイルを読み込んで引数のsourceにすることも可能 (@CsvFileSource)
BookshelfTest.java
import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class BookshelfTest {

    @ParameterizedTest
    @CsvSource({
        "0, JavaText",
        "1, PythonText",
        "2, RubyText"
    })
    void Bookshelfに3冊の本棚が格納できる(int index, String title) {
        Bookshelf sut = new Bookshelf();
        assertEquals(title, sut.addBook(index, title));
    }

}

タグ付け

  • 各テストコードに @tag アノテーションを付与することでタグ付けすることが可能
    • 指定したタグに紐づくテストのみを実行したり、実行から除外したりできる
BookshelfTest.java
import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

class BookshelfTest {

    @Test
    void addBookメソッドに本を1冊追加できる() {
        // 【事前準備】
        Bookshelf sut = new Bookshelf();
        String expected1 = "JavaText";

        // 【実行】
        String actual = sut.addBook(0, expected1);

        // 【検証】
        assertEquals(expected1, actual);
    }

    @Tag("異常系")
    @Test
    void インデックスに3以上を指定してaddBookメソッドを呼ぶとArrayIndexOutOfBoundsExceptionをthrowする() {
        // 【事前準備】
        Bookshelf sut = new Bookshelf();

        // 【実行】
        assertThrows(ArrayIndexOutOfBoundsException.class, () -> sut.addBook(3, "JavaTextBook"));
    }

}

2020年上期の開発の中を通して

  • privateメソッドのテストを行うかどうかは賛否が分かれるところである
    • プライベートメソッドのテストは書かないもの? を参照せよ
    • 開発前にチームとして指針を決めておく方が好ましい
    • privateメソッドのテストへの対応には例えば以下のようなものが挙げられる:
      • publicメソッド経由でテストする
      • 別クラスに切り出してpublicメソッドとする
      • テスト対象の可視性を (やや) 上げる
      • リフレクションでアクセスしてテストを書く
  • Lombokを用いて自動生成したメソッド (主にsetterやgetterなど) のテストも書いておく方が好ましい
    • もしLombok以外のOSSに切り替えた際に, 互換性を維持できているかの検証の最小単位はそのテストになる
    • ただし本PJではカバレッジ集計には含めない
  • カバレッジを上げるためだけにテストを書かないようにすること
    • カバレッジを上げること機能を評価すること の両方を意識してテストコードを書くこと
    • 例えばほぼ全てのテストがカバレッジを上げるためにメソッドを呼び出しているだけであれば, それはそのクラスの機能をテストしているとは言えない
  • テストコードにおける評価は正しく行うこと
    • 例えば, コレクションに何か要素を追加するメソッドをテストする際には, コレクションの要素数が追加した要素数と一致することだけではなく, コレクションの各要素が追加した要素と一致している事まで調べて初めて正しくテストしたといえる
  • ドキュメントとしてのテストコードを意識して書くこと
    • メソッドの使い方を解説する一番の説明書はテストコードである
  • 各自の環境でいつでも成功するテスト ( 再現性のあるテスト ) を作ること

参考文献

NegeLon
おじさんは すうれつが だいすきだぞ!
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away