OSSの通らない自動テストを追いかけた話

備忘録。

MyBatis Migrationsソースコード を落としてきて自動テストを叩いてみたけども通らないテストがあったんですよ。(masterブランチ commit 29945670f2937e3c9f107d9f59e6b03d7e58926e 2020-10-19版)

java.lang.AssertionError:
Expected: (an instance of java.io.FileNotFoundException and exception with message is "/tmp/NoSuchFile.sql (No such file or directory)")
but: exception with message is "/tmp/NoSuchFile.sql (No such file or directory)" message was "\tmp\NoSuchFile.sql (指定されたパスが見つかりません。)"
Stacktrace was: java.io.FileNotFoundException: \tmp\NoSuchFile.sql (指定されたパスが見つかりません。)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.(FileInputStream.java:138)
at java.io.FileReader.(FileReader.java:72)
at org.apache.ibatis.migration.commands.BaseCommand.copyTemplate(BaseCommand.java:173)
at org.apache.ibatis.migration.commands.BaseCommandTest.testNonexistentFile(BaseCommandTest.java:61)
(後略)

該当のテストコードはこちら

  @Test
  public void testNonexistentFile() throws Exception {
    String srcPath = "/tmp/NoSuchFile.sql";
    expectedException.expect(FileNotFoundException.class);
    expectedException.expectMessage(is(srcPath + " (No such file or directory)"));

    File dest = File.createTempFile("Out", ".sql");
    try {
      BaseCommand.copyTemplate(new File(srcPath), dest, null);
    } finally {
      dest.delete();
    }
  }

expectedException には @Rule がついてて、要するに実行してエラーが出たら、エラーメッセージが想定通りかを検証している自動テストなわけです。
スタックトレースから "/tmp/NoSuchFile.sql (No such file or directory)" が期待されるメッセージだったけど "\tmp\NoSuchFile.sql (指定されたパスが見つかりません。)" というメッセージだったから合致しなかったよ、ということが読み取れます(基本)

試したこと

JUnitの実行時のJavaVMのオプションに -Duser.language=en を設定する。
これはシステム・プロパティの値を設定するもの。
詳しくは java コマンドのリファレンス で -D のところを見てください。
システム・プロパティ "user.language" でデフォルトの言語ロケールを変更することができます。

しかし、これを設定してもテストは通らず。

自動テストに手を加えて、直前でプログラムでシステムプロパティを変更してみます。

System.setProperty("user.language", "en");

しかし、これでも効果なし。なぜ?

原因探索

言語の設定をしてもエラーメッセージが変わらないので FileNotFoundException が作られるところでどういうコードになっているのか探すことに。

at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.(FileInputStream.java:138)

トレースを辿ると FileInputStream の open0 メソッドで Native Method になってしまいました。OSに合わせたC言語のネイティブコードになってるわけですね。

でも大丈夫! JavaGPLオープンソースなのでネイティブメソッドもばっちり公開されています。
Javaのコードは元々Mercurialというバージョン管理ツールで管理・公開されていたんですが、世の中のシェアを鑑みて最近Gitに変わりました。(JEP357)


さて、OpenJDKのリポジトリのソースを見てみましょう。
今回のターゲットは共通部は src/java.base/share/native あたりに、OS固有のものは src/java.base/【OS名】/native あたりにあります。

まずは FileInputStream.c から

JNIEXPORT void JNICALL
Java_java_io_FileInputStream_open0(JNIEnv *env, jobject this, jstring path) {
    fileOpen(env, this, path, fis_fd, O_RDONLY);
}

さらに辿って io_util_md.c の fileOpen()

void
fileOpen(JNIEnv *env, jobject this, jstring path, jfieldID fid, int flags)
{
    FD h = winFileHandleOpen(env, path, flags);
    if (h >= 0) {
        jobject fdobj;
        jboolean append;
        fdobj = (*env)->GetObjectField(env, this, fid);
        if (fdobj != NULL) {
            // Set FD
            (*env)->SetLongField(env, fdobj, IO_handle_fdID, h);
            append = (flags & O_APPEND) == 0 ? JNI_FALSE : JNI_TRUE;
            (*env)->SetBooleanField(env, fdobj, IO_append_fdID, append);
        }
    }
}

同ファイル内のwinFileHandleOpen()

FD winFileHandleOpen(JNIEnv *env, jstring path, int flags)
{
/* 中略 */
    if (h == INVALID_HANDLE_VALUE) {
        throwFileNotFoundException(env, path);
        return -1;
    }
    return (jlong) h;
}

さらに io_util.c の throwFileNotFoundException()

void
throwFileNotFoundException(JNIEnv *env, jstring path)
{
    char buf[256];
    size_t n;
    jobject x;
    jstring why = NULL;

    n = getLastErrorString(buf, sizeof(buf));
    if (n > 0) {
        why = JNU_NewStringPlatform(env, buf);
        CHECK_NULL(why);
    }
    x = JNU_NewObjectByName(env,
                            "java/io/FileNotFoundException",
                            "(Ljava/lang/String;Ljava/lang/String;)V",
                            path, why);
    if (x != NULL) {
        (*env)->Throw(env, x);
    }
}

さらに jni_util_md.c の getLastErrorString()

JNIEXPORT size_t JNICALL
getLastErrorString(char *buf, size_t len) {

    DWORD errval;

    if ((errval = GetLastError()) != 0) {
        // DOS error
        size_t n = (size_t)FormatMessage(
                FORMAT_MESSAGE_FROM_SYSTEM|FORMAT_MESSAGE_IGNORE_INSERTS,
                NULL,
                errval,
                0,
                buf,
                (DWORD)len,
                NULL);
        if (n > 3) {
            // Drop final '.', CR, LF
            if (buf[n - 1] == '\n') n--;
            if (buf[n - 1] == '\r') n--;
            if (buf[n - 1] == '.') n--;
            buf[n] = '\0';
        }
        return n;
    }

    // C runtime error that has no corresponding DOS error code
    if (errno == 0 || len < 1) return 0;
    return strerror_s(buf, len, errno);
}

ここで FormatMessage というのが Win32 APIFormatMessage ということのようです。

DWORD FormatMessage(
DWORD dwFlags,
LPCVOID lpSource,
DWORD dwMessageId,
DWORD dwLanguageId,
LPTSTR lpBuffer,
DWORD nSize,
va_list *Arguments
);

というパラメータになっていて dwLanguageId に 0 が渡されていますね。

If you pass in zero, FormatMessage looks for a message for LANGIDs in the following order:

1. Language neutral
2. Thread LANGID, based on the thread's locale value
3. User default LANGID, based on the user's default locale value
4. System default LANGID, based on the system default locale value
5. US English

ここでメッセージが日本語にされていたようです。OSレベルのメッセージだったからJavaシステムプロパティで言語設定を変えても影響されず、日本語のメッセージが表示された、というわけでした。

結論

こんな自動テストは消してしまおう……!

ファイルパスの違いは JUnit の OS指定の @EnabledOnOs アノテーションで逃げることができるのですが、今回はメッセージの部分まで含んでいるのでこのアノテーションでの対処では不十分です。

OS に依存するメッセージを固定でテストするというのは過ぎた制約で、テストとしてはおよそやりすぎの類のものです。
テストコードに手を加えずに通したいなら言語設定が英語のmac使うといいんじゃないかな。