きり丸の技術日記

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

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

DBからJavaのResultSetにLocalDateTimeやBigInteger等の基本型以外の型を直接設定する

TwitterやJavaのオープンチャットででたまーにResultSetから直接LocalDateTimeBigIntegerを取得できずに、一旦Stringjava.sql.Dateで取得した後に変換している、ということを見かけることがありました。直接LocalDateTimeやBigIntegerで取得することができます。

今回の記事では、DBからJavaのResultSetを駆使し、JdbcTemplate#queryにてLocalDateTimeBigIntegerを取得する方法を記載します。

環境

  • Java
    • 15
  • org.springframework.boot
    • 2.5.2
  • org.flywaydb:flyway-core
    • 7.7.3
  • com.h2database:h2
    • 2.1.2.200
  • org.springframework.boot:spring-boot-starter-test
    • 2.5.2
  • org.springframework.boot:spring-boot-starter-jdbc
    • 2.5.2
  • org.mybatis.spring.boot:mybatis-spring-boot-starter
    • 2.2.0
  • org.mybatis.spring.boot:mybatis-spring-boot-starter-test
    • 2.2.0
  • Gradle

ゴール

JavaのJdbcTemplate#queryにてRLocalDateTimeBigIntegerを取得します。

要約

ResultSetgetObjectの第二パラメータに必要なクラスを指定します。

方法

事前準備

テーブルとデータを準備します。

CREATE TABLE BOOK
(
    isbn         VARCHAR(13) PRIMARY KEY,
    money        INT,
    number       INT,
    generate_date TIMESTAMP
);

INSERT INTO BOOK VALUES ('9784798126708', 1000, 10, '2021-07-02 12:34:56')

取得用のDtoを作成する

jdbcTemplateから取得するための型を用意します。

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BookDto implements Serializable {
  private String isbn;
  private int money;
  private BigInteger number;
  private LocalDateTime generateDate;
}

Javaで取得する

JdbcTemplate#queryを使用します。ResultSetgetObjectの第二パラメータに必要なクラスを指定します。

今回の場合は、BigInteger.class, LocalDateTime.classを指定することができます。

protected List<BookDto> findBookList() {
  return jdbcTemplate.query("SELECT * FROM BOOK", (rs, i) ->
      BookDto.builder()
          .isbn(rs.getString("isbn"))
          .money(rs.getInt("money"))
          .number(rs.getObject("number", BigInteger.class))
          .generateDate(rs.getObject("generate_date", LocalDateTime.class))
          .build());
}

備考

単純にラップした自作型に関してはマッピングできないようです。ISBNクラスを作成してマッピングしようとしましたが、エラーログが出力されました。

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BookDto implements Serializable {
  private Isbn isbn;
  private int money;
  private BigInteger number;
  private LocalDateTime generateDate;
}

@Value
@AllArgsConstructor
public class Isbn {
  private final String isbn;
}

protected List<BookDto> findBookList() {
  return jdbcTemplate.query("SELECT * FROM BOOK", (rs, i) ->
      BookDto.builder()
          .isbn(rs.getObject("isbn", Isbn.class))
          .money(rs.getInt("money"))
          .number(rs.getObject("number", BigInteger.class))
          .generateDate(rs.getObject("generate_date", LocalDateTime.class))
          .build());
}
Caused by: org.h2.jdbc.JdbcSQLFeatureNotSupportedException: 機能はサポートされていません: "kirimaru.biz.domain.book.Isbn"
Feature not supported: "kirimaru.biz.domain.book.Isbn" [50100-200]

素直に構造に合わせたRowMapperを作成しましょう。

        .isbn(new Isbn(rs.getString("isbn"))

ソースコード

単体テストも行えるようにしています。セットアップのためのFlyway等は今回の記事では解説しません。

終わりに

地味ながら、低くない頻度で見かけるので記事にしました。

やってることは簡単なので、困っている人に届くと嬉しいです。


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

f:id:nainaistar:20210702212124p:plain