JUnitでMockito使いながら大量データを処理するとメモリリークが発生するというお話

# 概要

Javaで開発しているプロジェクトにおいて、単体試験には大抵JUnitが使われていると思う。
またその際、試験対象のクラスが依存している別クラスを、モックライブラリを用いてモック化することもままある事だと思う。
モックライブラリにMockitoを使って大量データを処理するJUnitテストコードを実行した際、モックが原因でメモリリークが発生したため、備忘録として記録を残しておく。

# 経緯

以前従事していたあるJava開発プロジェクトでは、DBのデータ準備が自動化できることと性能観点でのデグレード確認を兼ねて、処理対象のDBデータが数百万件レベルの性能試験用のコードをJUnitで作成していた。
その際は一部のクラスをモックライブラリのMockitoを用いてモック化してテストコードを実行していたのだが、ある一定数データを処理した時点で必ずメモリが不足しOutOfMemoryErrorが発生していた。
ソースコードをどれだけ見直してもメモリリークが発生するような作りにはなっておらず、原因の究明に難航した。

で、ある時ふとひらめいた。
**「テストコードで使ってるMockitoって、モック化したメソッドが実行された回数とかを記録してるよな」** と。
恐らくメモリリークの原因はMockitoにあると当たりをつけ、検証することにした。

# 検証

### 検証条件

- openjdk 17.0.2
- JUnit 5.10.2
- SpringBoot 3.4.5
- Mockito 5.14.2
- Gradle 8.14

### 対象ソース

```java:DateUtil.java
@Component
public class DateUtil {
    public LocalDateTime getNow() {
        return LocalDateTime.now();
    }
}
```


```java:DateService.java
@Service
public class DateService {
    @Autowired
    private DateUtil dateUtil;

    private static final Logger log = LoggerFactory.getLogger(DateService.class);

    public void printNow() {
        for (int i = 0; i <= 10000000; i++) {
            LocalDateTime now = dateUtil.getNow();
            if (i % 1000 == 0) {
                log.info("ループ" + i + "回目：" + now.toString());
            }
        }
    }
}
```

単に現在時刻を取得するだけのDateUtilクラスをSpringの機能を用いてDIし、DateUtilクラスのgetNow()メソッドを一千万回呼び出して千ループごとにログに出力するというDateServiceクラスを定義する。
**当然ながらこのコードをただ実行しても、OutOfMemoryErrorは発生せずに一千万回の処理は無事終了する。**

### テストコード

上記のコードに対して、以下のテストコードを作成し実行する。

```java:DateServiceTest.java
@SpringBootTest
class DateServiceTest {

    @Autowired
    private DateService dateService;

    @MockitoBean
    private DateUtil dateUtil;

    @Test
    public void printNowTest() {

        when(dateUtil.getNow()).thenReturn(LocalDateTime.of(2025,1,1,12,34,56));

        dateService.printNow();
    }
}
```

DateUtilクラスを@MockitoBeanアノテーションによりモック化し、getNow()メソッドが実行された際は固定値として「2025年1月1日12時34分56秒」の日時を返すよう設定した上でDateServiceクラスのprintNow()メソッドを実行している。

※現在時刻を取得する処理をモック化して時刻を固定化するような事は、プログラミング言語を問わず単体試験全般でまま用いられるテクニックだろう。
デグレード確認の観点から言えば、何度実行しても結果が変わらないことを保証しなければならないのに、実行した日時によってテスト結果が変わるようであればそれはテストとして正しくないため。

このテストを実行すると、出力は以下のようになる。
※JVMのメモリサイズは最大512MBに設定している。

```
    2025-05-11T06:44:46.082Z  INFO 35722 --- [    Test worker] src.DateService                          : ループ1082000回目：2025-01-01T12:34:56
    2025-05-11T06:44:48.785Z  INFO 35722 --- [    Test worker] src.DateService                          : ループ1083000回目：2025-01-01T12:34:56
    2025-05-11T06:44:52.413Z  INFO 35722 --- [    Test worker] src.DateService                          : ループ1084000回目：2025-01-01T12:34:56

*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message can't create name string at src/java.instrument/share/native/libinstrument/JPLISAgent.c line: 838
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message can't create name string at src/java.instrument/share/native/libinstrument/JPLISAgent.c line: 838

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "Test worker"
```

**1084000回ほどループを回したあたりでOutOfMemoryErrorが発生した。**
「ただ時刻を取得してログに出力する」という至極単純なコードの一部をモック化しただけでメモリ容量が足りなくなるのであれば、その原因はモック化そのものにあると考えるのが妥当である。

### 対策

ではモック化を行いつつループを回してもメモリを抑えるにはどうすればいいのかというと、Mockitoがちゃんとその為のオプションを用意してくれていた。

https://site.mockito.org/javadoc/current/org/mockito/MockSettings.html#stubOnly()

```java
stubOnly
MockSettings stubOnly()
    A stub-only mock does not record method invocations, thus saving memory but disallowing verification of invocations.
    Example:

     List stubOnly = mock(List.class, withSettings().stubOnly());
 
    Returns:
        settings instance so that you can fluently specify other settings
```

ここに記載されているように、クラスをモック化する際にstubOnly()という設定を用いてやればモック化メソッドの実行を記録せずメモリ消費を抑えられるようである。

というわけで前述したテストコードを以下のように書き換えてみる。

```diff_java:DateServiceTest.java
@SpringBootTest
class DateServiceTest {

+   @TestConfiguration
+   public static class TestConf { // 本テストクラスにのみ適用されるSpringBootの設定クラスを定義
+       @Bean
+       public DateUtil dateUtil() {
+           // DateUtilクラスをstubOnly()を適用した上でモック化し、SpringのDIコンテナに登録
+           return mock(DateUtil.class, withSettings().stubOnly());
+       }
+   }

    @Autowired
    private DateService dateService;

-   @MockitoBean
+   @Autowired // MockitoBeanだと、TestConf内で登録したモックが更に上書きされてしまうため、AutowiredでDIする
    private DateUtil dateUtil;

    @Test
    public void printNowTest() {

        when(dateUtil.getNow()).thenReturn(LocalDateTime.of(2025,1,1,12,34,56));

        dateService.printNow();
    }
}
```

上記テストコードを実行すると、DateUtilクラスをモック化した上で**一千万回のループ処理が最後まで実行された。**

JavaでOutOfMemoryErrorが発生した際はソースコードのみに意識が行きがちだが、テストコードの作り方によってもメモリの消費量は変わってくるといういい経験になった。

### 検証に使用したコードはこちら

https://github.com/kng-coder/mockito-outofmemory-test

# 結論

JUnitのテストでメモリリークが発生した際は、試験対象のクラスだけでなくテストコードも注意した方がいいというお話でした。