きり丸の技術日記

技術・エンジニアのイベント・資格等はこちらにまとめる予定です

Javaで標準入力と標準出力を使ったコードをテストする

標準入力と標準出力をテストしようとすることは基本的にはありません。標準出力ではなく、Loggerに書き込まれていることを確認することが多いでしょう。

しかし、知っておいて損はありません。今回の記事では、FizzBuzzを使って標準入力と標準出力のテストを行います。

なお、新規性はありませんので、参考先の記事を見てみてください。

環境

  • Java
    • 15

仕様

  • 標準入力に1と入力したとき、標準出力には1が出力されること
  • 標準入力に2と入力したとき、標準出力には2が出力されること
  • 標準入力に3と入力したとき、標準出力にはFizzが出力されること
  • 標準出力に他の値が出力されていないこと

FizzBuzzの仕様

この記事では、FizzBuzzの仕様は以下の通りです。FizzBuzzそのもののロジックの説明はしません。

1から100までの数をプリントするプログラムを書け。ただし3の倍数のときは数の代わりに「Fizz」と、5の倍数のときは「Buzz」とプリントし、3と5両方の倍数の場合には「FizzBuzz」とプリントすること。

標準入力と標準出力のテスト

標準入力と標準出力の準備

定義として、標準入力はSystem.in、標準出力はSystem.outのことをを指します。まずは、データの設定、データの取得ができるようにSystem.inSystem.outを差し替えるクラスを作成します。

差し替える標準入力

InputStreamを継承しているクラスを作る必要があります。今回はStandardInputStreamという名前で作成します。テストしやすいようにデータを設定できるinputlnメソッドを用意します。

public class StandardInputStream extends InputStream {
  private StringBuilder sb = new StringBuilder();
  private String lf = System.getProperty("line.separator");

  /**
   * 文字列を入力する。改行は自動的に行う
   *
   * @param str 入力文字列
   */
  public void inputln(String str) {
    sb.append(str).append(lf);
  }

  @Override
  public int read() {
    if (sb.length() == 0) return -1;
    int result = sb.charAt(0);
    sb.deleteCharAt(0);
    return result;
  }
}

差し替える標準出力

PrintStreamを継承しているクラスを作る必要があります。今回はStandardOutputStreamという名前で作成します。標準出力したデータを取得できるreadLineメソッドを用意します。

public class StandardOutputStream extends PrintStream {
    private BufferedReader br = new BufferedReader(new StringReader(""));

    public StandardOutputStream() {
        super(new ByteArrayOutputStream());
    }

    /**
     * 1行分の文字列を読み込む
     * @return 改行を含まない文字。終端の場合はnull
     */
    public String readLine() {
        String line = "";
        try {
            if ((line = br.readLine()) != null) return line;
            br = new BufferedReader(new StringReader(out.toString()));
            ((ByteArrayOutputStream) out).reset();
            return br.readLine();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

テスト対象のプロダクトコード

FizzBuzzの変換クラスと、標準入力から受け取った値をFizzBuzzクラスに渡して標準出力に渡すクラスを作成します。

public class FizzBuzz {
  public String convert(int input) {
    var sb = new StringBuilder();
    if (input % 3 == 0) sb.append("Fizz");
    if (input % 5 == 0) sb.append("Buzz");
    if (sb.length() == 0) sb.append(input);
    return sb.toString();
  }
}

public class FizzBuzzInput {
  public void main() {
    FizzBuzz fizzBuzz = new FizzBuzz();
    try (Scanner sc = new Scanner(System.in)) {
      System.out.println(fizzBuzz.convert(sc.nextInt()));
      System.out.println(fizzBuzz.convert(sc.nextInt()));
      System.out.println(fizzBuzz.convert(sc.nextInt()));
    }
  }
}

テストコード

標準入力はSystem.setIn、標準出力はSystem.setOutで差し替えることができます。

@BeforeEachでテストを実行するたびに、先ほど作成したStandardInputStreamStandardOutputStreamを設定します。なお、Systemの値はグローバルなので、そのままに設定していると他のクラスに影響が出てしまいます。@AfterEachSystem.setInSystem.setOutに初期値を設定することを忘れないようにしましょう。

初期値については、Java15でのSystemクラスで確認しておりますが、最新版で処理を確認しておくと良いでしょう。

private StandardInputStream in = new StandardInputStream();
private StandardOutputStream out = new StandardOutputStream();

@BeforeEach
public void before() {
  System.setIn(in);
  System.setOut(out);
}

  @AfterEach
  void tearDown() throws Exception {
    System.setIn(new BufferedInputStream(new FileInputStream(FileDescriptor.in)));
    System.setOut(new PrintStream(new BufferedOutputStream(new FileOutputStream(FileDescriptor.out), 128), true, StandardCharsets.UTF_8));
  }

標準入力にデータを設定するinputlnメソッド、標準出力からデータを取得するreadLineメソッドを用意したので、そこからテストデータのセット、及びデータの検証をしましょう。今回は、標準入力に1, 2, 3を渡し、標準出力に1, 2, Fizzが取得できることを確認します。

  FizzBuzzInput target = new FizzBuzzInput();

  @Test
  public void _1and2AndFizzAndNull() {
    // Given
    in.inputln("1");
    in.inputln("2");
    in.inputln("3");
    // When
    target.main();

    // Then
    assertThat(out.readLine()).isEqualTo("1");
    assertThat(out.readLine()).isEqualTo("2");
    assertThat(out.readLine()).isEqualTo("Fizz");
    assertThat(out.readLine()).isNull();
  }

厳密なFizzBuzz問題

TDDでの演習を行う場合、変換するロジックだけをテストします。しかし、次の強調した箇所に関しては無視されることが多いです。

**1から100**までの数を**プリント**するプログラムを書け。ただし3の倍数のときは数の代わりに「Fizz」と、5の倍数のときは「Buzz」と**プリント**し、3と5両方の倍数の場合には「FizzBuzz」と**プリント**すること。

プロダクトコード

このプリントは標準出力と読み替えることができます。厳密に仕様を満たすと次のようなコードになるのではないでしょうか。

public class FizzBuzz {
  public void execute() {
    // 1から100まで実行
    for (int i = 1; i <= 100; i++) {
      // プリントする
      System.out.println(convert(i));
    }
  }
  // 変換する
  public String convert(int input) {
    // 省略
  }
}

テストコード

このFizzBuzzの厳密な定義で作ったexecuteメソッドのテストは次の観点を含むので、初心者には非常に難しいです。

  1. 標準出力のテストが難しい
  2. リターンの値が無いので容易にテストができない
  3. 本当に1-100まで出力しているか分からない
  4. 1-100まで順番に出力していない可能性があるかもしれない

しかも、何も考えずにテストしてしまうと、アサーションルーレットになってしまいます。妥協案としては、SoftAssertionsを使ってアサーションルーレットを回避するべきではないでしょうか。

  @Test
  void execute() {
    target.execute();
    int i = 0;
    SoftAssertions softly = new SoftAssertions();
    String actual = out.readLine();

    // 標準出力の値と比較
    while (actual != null) {
      softly.assertThat(target.convert(++i)).isEqualTo(actual);
      actual = out.readLine();
    }
    // 標準出力に書き込まれた回数の比較
    softly.assertThat(100).isEqualTo(i);
    softly.assertAll();
  }

備考

ポートアンドアダプターで考えると、標準入力、標準出力の機能はコアではなく、アダプターの機能です。ポートアンドアダプターについては、類似記事を参考にしてみてください。

ソースコード

終わりに

TDDでのFizzBuzzはTDDのリズムを学んでもらうもので、標準出力にこだわるべきではありません。しかし、TDDを理解するためにあえて仕様を無視をするのと、仕様を無視する癖をつけてしまうのは違います。

基本的に仕様を無視して良い理由などありませんので、伝えたいことがぼやけない様にFizzBuzzのお題を提供する側も注意しないといけませんね。


あと、私はやっていないのですが、AtCoder等の競技プログラミングでは標準入力でデータを受け取るようなので、ローカルでテストができるように標準入力の差し替えを素振りしておくといいかもしれません。


この記事お役に立ちましたら、各種SNSでのシェアや、今後も情報発信しますのでフォローよろしくお願いします。

参考記事

類似記事

f:id:nainaistar:20210722141956p:plain