#WebAPIの単体テストと結合テスト
PJでWebAPIを新規作成するにあたり、Junitでどのようなテストが作成できるか調べたのでメモ。
##目次
###テストコード
###テスト対象コード
- EmployeeController.class
- GetEmployeeUsecase.class
- Employee.class
- EmployeeRepository.class
- TempEmployeeRepositoryImp.class
##ソースコード
すべてgithub上にもあります。
https://github.com/tmtmra/restControllerTestSample
##1.単体テスト(クラス単位でのテスト)
他のクラスへ依存せず、mockitoでモックを挿入する。他のクラスの実装を待たずテストが可能。
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import com.example.demo.domain.Employee;
import com.example.demo.usecase.GetEmployeeUsecase;
@WebMvcTest(EmployeeController.class) //※1
class EmployeeControllerSliceTest {
/** テストターゲットにインジェクションするモック */
@MockBean //※2
GetEmployeeUsecase mockedUsecase;
/** 接続テスト用のクライアント */
@Autowired
private MockMvc mvc; //※3
@Test
void testGetOne() throws Exception {
//モックの動きを設定する
when(this.mockedUsecase.getEmployee(anyString())).thenReturn(Optional.ofNullable(new Employee("foo", "bar")));
//テストを実行
this.mvc.perform(get("/employees/{employeeId}", "123")) //id指定は何でもよい。
.andExpect(status().is(200)) //
.andExpect(content().json("{\"employeeId\":\"foo\",\"name\":\"bar\"}")); //クエリに関係なくモックのデータ
}
}
説明
@WebMvcTestを用いると、コントローラーの動作を確認するのに最低限のbeanのみがロードされたテストが可能。ほかの3パターンと比べて、実行時間が短いので、開発中やリファクタリング中の比較的短いサイクルでのテスト実施がストレスなく実施できます。
※1 クラスに@WebMvcTestを指定。valueにはテストターゲットのコントローラークラスを指定する。
※2 フィールドに@MockBeanを指定。1で指定したコントローラーにモックインジェクションしたいbeanを指定する。
※3 フィールドにMockMvcを宣言し@Autowiredする。接続テストクライアント。
参考
Auto-configured Spring MVC Tests
Mocking and Spying Beans
##2.結合テスト1(任意の複数クラスの結合)
明示的に他のクラスへの依存を宣言してテスト可能。
package com.example.demo.presentation;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import com.example.demo.infrastructure.TempEmployeeRepositoryImp;
import com.example.demo.usecase.GetEmployeeUsecase;
@WebMvcTest(EmployeeController.class) //※1
@Import({ GetEmployeeUsecase.class, TempEmployeeRepositoryImp.class }) //※2
class EmployeeControllerSliceAndImportTest {
/** 接続テスト用のクライアント */
@Autowired
private MockMvc mvc; //※3
@Test
void testGetOne() throws Exception {
//実行
this.mvc.perform(get("/employees/{employeeId}", "123")) //
//確認
.andExpect(status().is(200)) //
.andExpect(content().json("{\"employeeId\":\"123\",\"name\":\"Taro\"}")); //Repositoryから取得したデータ
}
}
説明
@Importを用いると、@WebMvcTestのテストに明示的にbeanを追加できます。特定のクラス間の結合をテストしたい場合に使用できます。
※1 クラスに@WebMvcTestを指定。valueにはテストターゲットのコントローラークラスを指定する。
※2 クラスに@importを指定。valueにはテスト環境に追加でロードしたいbeanのクラスを指定します。
※3 フィールドにMockMvcを宣言し@Autowiredする。接続テストクライアント。
参考
Auto-configured Spring MVC Tests
##3.結合テスト2(全クラスの結合、ローカル環境)
ほぼ実際のサーバー起動時同様の環境でローカルテスト可能。
package com.example.demo.presentation;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) //※1
class EmployeeControllerOnLocalhostTest {
/** 接続テスト用のクライアント */
WebTestClient client = WebTestClient.bindToServer().build(); //※2
/** 接続先のポート番号 */
@LocalServerPort //※3
private int port;
@Test
void test() throws Exception {
//実行
this.client.get().uri("http://localhost:" + this.port + "/employees/{employeeId}", "123").exchange() //
//確認
.expectStatus().isEqualTo(HttpStatus.valueOf(200)) //
.expectBody(String.class).isEqualTo("{\"employeeId\":\"123\",\"name\":\"Taro\"}");
}
}
説明
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)を用いると、ほぼ完全なサーバー上での接続テストが可能です。サーバーの起動からJunitの実行までワンクリックで実行されます。比較的実行時間は長くなります。
※1 クラスに@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)を指定。ランダムポート番号でサーバーが立ち上がります。
※2 フィールドにWebTestClientを宣言し初期化します。接続テストクライアント。
※3 フィールドに@LocalServerPortを指定して、ポート番号を取得します。
参考
Testing with a running server
WebTestClient
Spring WebClient Requests with Parameters
##4.結合テスト3(全クラスの結合、任意の環境)
任意の起動中のサーバーへ接続してその挙動をテスト可能。
package com.example.demo.presentation;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
class EmployeeControllerOnExternalServerTest {
/** 接続テスト用のクライアント */
WebTestClient client = WebTestClient.bindToServer().build(); //※1
@Test
void test() throws Exception {
//実行
this.client.get().uri("http://localhost:8080/employees/{employeeId}", "123").exchange() //※2
//確認
.expectStatus().isEqualTo(HttpStatus.valueOf(200)) //
.expectBody(String.class).isEqualTo("{\"employeeId\":\"123\",\"name\":\"Taro\"}");
}
}
説明
Junitからサーバー起動をキックしないため、テスト実行前に別途サーバーを起動しておくことが必要。逆に言うと、起動しているWebAPIサーバーなら何でもテスト可能。Javaでなくてもよい。
※1 フィールドにWebTestClientを宣言し初期化します。接続テストクライアント。
※2 任意のURLを指定可能。
参考
WebTestClient
Spring WebClient Requests with Parameters
##テスト対象コード
###EmployeeController.java
package com.example.demo.presentation;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.domain.Employee;
import com.example.demo.usecase.GetEmployeeUsecase;
@RestController
public class EmployeeController {
@Autowired
GetEmployeeUsecase getUsecase;
@GetMapping("/employees/{employeeId}")
Optional<Employee> getOne(@PathVariable final String employeeId) {
return this.getUsecase.getEmployee(employeeId);
}
}
###GetEmployeeUsecase.java
package com.example.demo.usecase;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.example.demo.domain.Employee;
import com.example.demo.domain.EmployeeRepository;
@Service
public class GetEmployeeUsecase {
@Autowired
EmployeeRepository repository;
public Optional<Employee> getEmployee(final String employeeId) {
return this.repository.findOneById(employeeId);
}
}
###Employee.java
package com.example.demo.domain;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class Employee {
private String employeeId = null;
private String name = null;
@SuppressWarnings("unused")
private Employee() {
}
}
###EmployeeRepository.java
package com.example.demo.domain;
import java.util.Optional;
import org.springframework.stereotype.Repository;
public interface EmployeeRepository {
Optional<Employee> findOneById(String employeeId);
}
###TempEmployeeRepositoryImp.java
package com.example.demo.infrastructure;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
import com.example.demo.domain.Employee;
import com.example.demo.domain.EmployeeRepository;
@Repository
public class TempEmployeeRepositoryImp implements EmployeeRepository {
private final List<Employee> db; //簡易DBとして
public TempEmployeeRepositoryImp() {
this.db = new ArrayList<>();
//初期データとして
this.db.add(new Employee("123", "Taro"));
}
@Override
public Optional<Employee> findOneById(final String employeeId) {
return this.db.stream().filter(e -> e.getEmployeeId().equals(employeeId)).findFirst();
}
}
##最後に
1年前にプログラミング始めた初心者なので、ぜひいろいろご意見アドバイスください。