きり丸の技術日記

技術検証したり、資格等をここに残していきます。

Javaで事前条件を満たしていないテストを安全に終了させる(AssumeTrue)

たとえばDBは本番環境ではOracle、テスト環境ではH2を使っているケースがあるとします。その場合、Oracleでは動くがH2では動かないSQLを作成してしまうことが考えられます。

他にも、本番環境はAmazon RDS for Oracleを使っているが、テスト環境で用意できているOracleのバージョンが合わないパターンもあるでしょう。バージョンによっては動かないSQLを作成してしまうことも考えられます(例:ALTER SEQUENCEはOracle 18cから使えるが、それ以前のバージョンはDROP/CREATEするしかない)。

この記事ではAssumeTrueを使用して、事前条件を満たせないテストがあった時に、テストを安全に終了させます。

ゴール

事前条件を満たしていないテストを終了させる。ただし、テスト終了後はDisabledのように、正常終了とも異常終了とも異なる状態にする。

f:id:nainaistar:20210801220220p:plain

環境

  • Java
    • 15
  • org.springframework.boot
    • 2.5.2
  • JUnit
    • 5.7.1

ユースケース

  • 特定の環境でしか動かないテストを特定の環境だけで動くようにする
    • OS
    • DB
    • 等々

      対応

大きく2つ、AssumeTrueAssumingThatのメソッドがあります。

AssumeTrue

AssumeTrueでは事前条件を満たせていない場合、以後の処理を停止させます。

使い方として、org.junit.jupiter.api.Assumptions#assumeTrueの第1パラメータに事前条件を記載します。第2パラメータに失敗時のメッセージを記載します。

本来であればorg.junit.jupiter.api.Assertions.failで失敗しますが、AssumeTrue以後の処理を停止させているために安全に終了します。

@Test
void test_01() {
    assumeTrue(true);
    fail();

    // 結果メッセージ
    // Assumption failed: assumption is not true
}

@Test
void test_02() {
    assumeTrue(true, "このテストはoracle環境では動きません");
    fail();

    // 結果メッセージ
    // Assumption failed: このテストはoracle環境では動きません。
}

なお、AssumeFalseも存在しますが、出力するメッセージが混乱してしまったので、私は推奨しません。

AssumingThat

AssumingThatでは事前条件を満たせていない場合でも、以後の処理を実行します。特定の環境のみ追加テストを行うイメージでよいでしょう。

使い方として、org.junit.jupiter.api.Assumptions#assumingThatの第1パラメータに事前条件を記載します。第2パラメータに追加テストを記載します。

@Test
void test_01() {
    assumingThat(
        true,
        () -> {
            // ここでエラーになったら処理終了する。
            assertThat(1).isEqualTo(1);
        }
    );

    // assumingThatはここも実行する
    // fail();
}

なお、機能として紹介はしますが個人的にはAssumingThatは推奨しません。理由としては条件分岐が入ったことにより、テストが複雑になるからです。

備考

今回紹介したメソッドで事前条件に一致しない場合、TestAbortedExceptionを発生させます。JUnit 5単体で使用している場合は安全に処理はしてくれますが、メッセージを出力してくれません。

もし、メッセージを確認したい場合は、@SpringJUnitConfig等のフレームワークのテストアノテーションを使いましょう。

# ログ抜粋
21:35:12.828 [Test worker] 
... - After test method: context [DefaultTestContext@799f7b8a testClass = AssumeTests.AssumeTrue, testInstance = kirimaru.biz.domain.AssumeTests$AssumeTrue@1836d0cc, testMethod = test_02@AssumeTests.AssumeTrue, testException = org.opentest4j.TestAbortedException: Assumption failed: このテストはoracle環境では動きません
...

なお、@SpringBootTestだとメッセージを出力してくれなかったので、あんまりメッセージを作りこんでも無意味かもしれません。AssumeTrueで事前条件を満たさなかったときに、標準出力にメッセージを設定する方法がわからなかったので、教えていただけると助かります…。

ちなみに

そもそも、Springの機能にorg.springframework.test.context.junit.jupiter.DisabledIfというもっと簡単に使えそうな機能がありました。

軽い気持ちで「Disabled」に「If」使えたら楽だなーって思っていたのが、そのままありました。

こちらはまったく検証していませんので、DisabledIfも検証して、今後記事にする予定です。

終わりに

ピュアな機能として学ぶことも大事ですが、やはりライブラリは便利な機能を提供してますね。ライブラリの機能って本当に知らない機能は知らないから困る…。

ま、まぁ、こういうことができる、ということがわかっていれば他の言語でも同様の機能がありそうだ、というアタリをつけられるので、1つ成長したと思うことにします。


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

参考

f:id:nainaistar:20210801220345p:plain

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

Mavenのproxy設定は1つしか有効に設定できない

タイトルの出オチ記事。これを知るためだけに4日くらい無駄にしました。参考先のブログに感謝です。

環境

  • Maven
    • 3.6.0

結論

Mavenでproxyを通す設定は、~/.m2/settings.xmlに設定する必要があります。

ただし、~/.m2/settings.xmlに複数定義できても、設定は1つしか有効にできません。そのため、~/.m2/settings.xmlにhttp, httpsの両方とも設定すると片方だけしか有効になりません。

settings.xmlのproxy設定

項目 設定内容
id 一意な値を設定する
active settings.xmlで有効にしたいproxy設定。1つしか設定できない
protocol httpまたはhttps
host プロキシのIPやドメイン
port プロキシのポート
username プロキシへのログインユーザ名
password プロキシへのログインパスワード
nonProxyHosts プロキシを通さないIPやドメイン

具体的な例としては以下の通りです。

<settings>
    <proxies>
        <proxy>
            <id>id01</id>
            <!-- 1つだけしかActiveにできない -->
            <active>false</active>
            <protocol>http</protocol>
            <host>proxy-domain</host>
            <port>80</port>
            <username>proxyUser</username>
            <password>proxyPass</password>
            <nonProxyHosts>localhost|127.0.0.1</nonProxyHosts>
        </proxy>
        <proxy>
            <id>id02</id>
            <active>true</active>
            <protocol>https</protocol>
            <host>proxy-domain</host>
            <port>80</port>
            <username>proxyUser</username>
            <password>proxyPass</password>
            <nonProxyHosts>localhost|127.0.0.1</nonProxyHosts>
        </proxy>
    </proxies>
</settings>

HTTPもHTTPSも有効にしたい

もしHTTP, HTTPSの両方ともproxy設定も有効にしたい場合は、環境変数のMAVEN_OPSにて、VMの起動オプションに設定する必要があるようです。(未検証)

# Windows
set MAVEN_OPTS=-Dhttp.proxyHost=<host> -Dhttp.proxyPort=<port> -Dhttps.proxyHost=<host> -Dhttps.proxyPort=<port>
# Mac/Linux
export MAVEN_OPTS=-Dhttp.proxyHost=<host> -Dhttp.proxyPort=<port> -Dhttps.proxyHost=<host> -Dhttps.proxyPort=<port>

終わりに

MavenのProxy設定する記事はたくさん見つかりましたが、1つしか設定できないという記事を見つけるのは大変でした。もし、HTTPSだけしか設定していなければここまで苦労しなかったですし、HTTPS, HTTPの順番で設定していたなら最初からうまく行っていたと思います。

proxy設定は難しいですね…。


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

参考

f:id:nainaistar:20210715171702p:plain