記事の内容
この記事ではSpring Bootで作った様々なAPI、メソッドに対してSpockを用いてテストを行う方法について紹介します。Spockの基本的な書き方、構造の説明もしています。
Spockの導入方法については以下を参考にしてください。
この記事では以下の記事で作成したMySQLを用いたプロジェクトを対象にテストを導入していきます。
Spockでのテストの書き方
まず、Spockでテストを書く前にSpockのテストの書き方について説明します。
テストコードとしてHealthTest.groovyがあったときその中にあるメソッドは以下の構造で書きます。
1 2 3 4 5 6 7 8 9 10 11 |
class HealthTest extends Specification { def "どのメソッドやAPIに対してどんなテストを行うかわかる説明" () { //① given: "DIするクラスのモック化や値を宣言するところ" //② .... when: "実際にテストしたい処理が実行されるところ" //③ .... then: "whenが実行されたときどのようなことな結果になって欲しいか評価するところ" //④ .... } def ""... |
①の部分ではdefの隣の””の中にこのテストメソッドがどのようなことを行うか端的にわかるように書きます。
テストメソッドの中は②〜④のgiven, when, thenの3つで区切られており、given:以下にはDIするクラスのモック化や値を宣言する処理を書き、when:以下にはテストしたい処理を書きます。then:以下にはwhenの処理が行われたときにどのような結果、値になって欲しいかを評価するコードを書きます。
また、give:, when:, then: の隣の””内には説明を書くことができテストの見通しをよくしたり、理解し易くすることができます。
より具体的な例を書くと例えば以下のようなHealth.javaがあったときに、このクラスのテストを行うHealth.groovyは以下のように書くことができます。
Health.java
1 2 3 4 5 |
public class Health { public String getHealth() { return "OK"; } } |
HealthTest.groovy
1 2 3 4 5 6 7 8 9 10 |
class HealthTest extends Specification { def "getHealth" () { given: "テスト対象のクラスを準備" def target = new Health() when: "テスト対象のメソッド実行" def response = target.getHealth() then: "メソッドのレスポンスが正しいか確認" assert response == "OK" } } |
テストが複雑でなければgive:, when:, then:の横の説明は省略してもよいと思います。
実際に様々なテストを書いてみる
メソッドやAPIの動作確認テスト
それでは実際に様々なAPI、メソッドに対してテストを書いていきます。
例えば、BookControllerのBookの1件取得APIのテストの行い方を詳しく説明します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@RestController @RequestMapping("/book") public class BookController { .... @Autowired BookService bookService; @RequestMapping(method = RequestMethod.GET, path = "/{bookId}") public ResponseEntity<Book> getBook(@PathVariable(value = "bookId") int bookId) { return new ResponseEntity<>(bookService.getBook(bookId), HttpStatus.OK); } .... } |
このAPIの単純なメソッドに対するテストは以下のように行う。
BookControllerTest.groovy
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class BookControllerTest extends Specification { def "method test getBook" () { given: def target = new BookController() target.bookService = Mock(BookService) 1 * target.bookService.getBook(1) >> new Book(1, "my title", "my description", false) when: def response = target.getBook(1) then: assert response.getStatusCode() == HttpStatus.OK assert response.getBody() == new Book(1, "my title", "my description", false) } } |
上記テストの書き方について説明すると、
def target = new BookController()でテスト対象(target)のクラスを用意。
target.bookService = Mock(BookService)でBookControllerがDI(Autowired)しているBookServiceをモック化。
1 * target.bookService.getBook(1) >> new Book(1, “my title”, “my description”, false)でbookServiceのgetBookメソッドが()内の引数で呼ばれたら>>以下のものを返す。1 * 部分はbookService.getBookが1回だけ呼ばれたことをチェックする為のものである。
つまり、この部分はtarget内のbookService.getBookが引数に1を渡されて1回実行したときは、new Book(1, “my title”, “my description”, false)を返すということである。
補足として、この部分の書き方としてはこのモック化したメソッドが1回実行することを確認する必要がなければ_ * と書いても良い。また、引数に何が渡されても良い場合は.getBook(_)と書くこともできる。
次に、このAPIに対してAPIの動作を確認するテストを行う。
テストメソッドは以下のように書く。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
def "api test getBook" () { given: def target = new BookController() def mockMvc = MockMvcBuilders.standaloneSetup(target).build() target.bookService = Mock(BookService) 1 * target.bookService.getBook(1) >> new Book(1, "my title", "my description", false) when: def response = mockMvc.perform(get("/book/1")) then: response.andExpect(status().isOk()) assert response.andReturn().getResponse().getContentAsString() == '{"bookId":1,"title":"my title","description":"my description","deleteFlag":false}' } |
単純なメソッドテストの違いとしてAPIのテストではdef mockMvc = MockMvcBuilders.standaloneSetup(target).build()の部分でtargetとなるAPIをモックとして起動している。
そして、def response = mockMvc.perform(get(“/book/1”))の部分でAPIに対してリクエストを投げている。
response.andExpect(status().isOk())ではAPIから返ってきたレスポンスのステータスコードが200 OKであることを確認し、assert response.andReturn().getResponse().getContentAsString() == ‘{“bookId”:1,”title”:”my title”,”description”:”my description”,”deleteFlag”:false}’ではレスポンスの中から取り出したレスポンスボディを文字列化してみて想定通りの値になっているかをチェックしている。(レスポンスボディをStringしてチェックする必要はなく値を一つ一つ取り出して確認しても良いです。また、ステータスコード以外にもレスポンスがJSONかなどチェックすることもできます)
イテレーションでテストを行う
上記のテストにおいてリクエストする内容やレスポンスする内容を変えていくつかのテストをしたい場合、Spockにあるイテレーションを使うことで簡単に行うことができる。
イテレーションを使うには以下のようにコードを変更する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Unroll def "api test getBook bookId = #bookId, titile = #title"() { given: def target = new BookController() def mockMvc = MockMvcBuilders.standaloneSetup(target).build() target.bookService = Mock(BookService) 1 * target.bookService.getBook(bookId) >> new Book(bookId, title, description, false) when: def response = mockMvc.perform(get("/book/" + bookId)) then: response.andExpect(status().isOk()) assert response.andReturn().getResponse().getContentAsString() == expectedResponseBody where: bookId | title | description || expectedResponseBody 1 | "my title 1" | "my description 1" || '{"bookId":1,"title":"my title 1","description":"my description 1","deleteFlag":false}' 2 | "my title 2" | "my description 2" || '{"bookId":2,"title":"my title 2","description":"my description 2","deleteFlag":false}' } |
メソッドに@Unrollをつけることでそのメソッド内のwhere:で指定された値を一行ずつメソッド内の値に代入しテストをイテレーションで実行してくれる。
例えば上記メソッドを実行すると、1つ目のテストとしてbookId=1,title=”my title 1″,description=”my description 1″というように値が代入されてテストが実行され、次にbookId=2,title=”my title 2″,description=”my description 2″というように値が代入されテストが実行される。where以下に何行書かれてもその行分だけ同じように実行される。
(@UnrollをつけたときNo tests found for given includes…というエラーがでたら[Preferences] > [Build, Execution, Deployment] > [Build Tools] > [Gradle] > Run Tests usingをIntelliJ IDEAにする必要がある)
メソッド名のdefの隣の””の中に#bookId, #titleと書くことでテスト実行時に値が代入されて表示されるのでどのテストで成功、失敗したかがすぐにわかるようになる。

様々なパターンのテストを書いてみる
まだテストを書いていないクラスのメソッドをいくつか書いていく。
BookControllerのBookの作成APIのテストの行い方を書くと、BookControllerが以下のようにあるので、
1 2 3 4 |
@RequestMapping(method = RequestMethod.POST, path = "/") public ResponseEntity<Integer> addBook(@RequestBody Book book) { return new ResponseEntity<>(bookService.addBook(book), HttpStatus.CREATED); } |
テストメソッドは以下のように書く。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
def "addBook"() { given: def target = new BookController() def mockMvc = MockMvcBuilders.standaloneSetup(target).build() target.bookService = Mock(BookService) def request = new StringBuffer() .append('{') .append('"title": "my title",') .append('"description": "my description"') .append('}') .toString() def book = new Book() book.setTitle("my title") book.setDescription("my description") 1 * target.bookService.addBook(book) >> 1 when: def response = mockMvc.perform(post("/book/") .contentType(MediaType.APPLICATION_JSON) .content(request)) then: response.andExpect(status().isCreated()) assert response.andReturn().getResponse().getContentAsString() == "1" } |
def request = new StringBuffer()の部分でrequestするパラメータをjson構造で作成している。
POSTメソッドのAPIにアクセスするのでmockMvc.perform(post(“/book/”)でpostであることを宣言し、.contentType(MediaType.APPLICATION_JSON)でリクエスト内容がjson構造であることを定義している。
response.andExpect(status().isCreated())は今回POSTメソッドで成功したときのHTTPステータスは201 created なのでisCreated()にしている。
インラインDB h2を用いてデータ操作のテストをする
次にBookRepositoryのテストを行ってみる。
Repositoryのテストは実際にDBからデータ取得したり更新したりしてみるので、インラインDBのh2を使う。
h2の設定にはbuild.gradleに以下のh2のライブラリを入れる。
1 2 3 |
dependencies { testCompile group: 'com.h2database', name: 'h2' } |
src/test以下にresourcesフォルダを作りその下にapplication-test.ymlを追加する。
application-test.yml
1 2 3 4 5 6 7 8 9 |
spring: datasource: url: jdbc:h2:mem:;DB_CLOSE_ON_EXIT=TRUE;MODE=MYSQL username: sa password: driverClassName: org.h2.Driver jpa: hibernate: ddl-auto: none |
インラインDBのh2を用いたテストで使うテーブルの作成定義をschema.sqlに、使うデータをdata.sqlに定義する。どちらもsrc/test/resources以下である。
schema.sql
1 2 3 4 5 6 7 |
CREATE TABLE `book` ( `book_id` int(11) NOT NULL AUTO_INCREMENT, `delete_flag` tinyint(1) NOT NULL DEFAULT '0', `description` varchar(255) DEFAULT NULL, `title` varchar(255) DEFAULT NULL, PRIMARY KEY (`book_id`) ) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; |
data.sql
1 2 |
INSERT INTO book (book_id, title, description, delete_flag) VALUES(1, 'my title 1', 'my description 1', 0), (2, 'my title 2', 'my description 2', 0); |
BookRepositoryの一件のデータ取得とレコードの作成メソッドは以下のようになっている。
BookRepository.java
1 2 3 4 5 6 7 8 9 |
@Repository public interface BookRepository extends JpaRepository<Book, Integer> { Book findByBookId(Integer id); @Override @Transactional Book save(Book book); } |
このメソッドに対してテストを行うには以下のように書く。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
@SpringBootTest @ActiveProfiles("test") class BookRepositoryTest extends Specification { @Autowired BookRepository bookRepository def "findByBookId" () { given: def book = new Book(1, "my title 1", "my description 1", false) when: def response = bookRepository.findByBookId(1) then: assert response == book } def "save" () { given: def book = new Book() book.setTitle("my title 3") book.setDescription("my description 3") def expectedBook = new Book(3, "my title 3", "my description 3", false) when: def response = bookRepository.save(book) then: assert expectedBook == response } } |
@SpringBootTest, @ActiveProfiles(“test”)と書くことでこのテストの起動時にapplication-test.ymlを読み込み、schema.sqlのテーブルを作り、data.sqlのデータを挿入してくれるようになる。
ActiveProfilesの”test”と書いている箇所はapplication-test.ymlのハイフン以下の”test”の部分で紐づいてファイルを読み込んでいる。
他のクラスと違ってBookRepositoryはinterfaceなのでこれ自体をテストするにはAutowiredしなければならない。
def response = bookRepository.findByBookId(1)とすることでbookId=1のデータを取得できる。なぜ取得できるかはdata.sqlがすでにh2のDBで実行されてデータができているからである。
def response = bookRepository.save(book)でresponseのbookId=3なのはdata.sqlでbookId=1,2のデータは作っているからである。
@SpringBootTestはモック化ではなく実際にプロジェクトを起動しているので、今回はBookRepositoryのみで使ったが、BookControllerでも使うことで、API全体を通したテストやAPIのパラメータのバリデーションテストなども行うことができる。