自信を持って構築する:JUnitテストのガイド

公開: 2022-03-11

ウォーターフォールモデルからアジャイル、そして今ではDevOpsに移行するテクノロジーと業界の進歩に伴い、アプリケーションの変更と拡張機能は、作成された瞬間に本番環境に展開されます。 コードがこれほど速く本番環境にデプロイされるので、変更が機能し、既存の機能が損なわれないことを確信する必要があります。

この信頼を築くには、自動回帰テストのフレームワークが必要です。 回帰テストを行うには、APIレベルの観点から実行する必要のあるテストが多数ありますが、ここでは2つの主要なタイプのテストについて説明します。

  • 単体テスト。特定のテストがプログラムの最小単位(関数またはプロシージャ)を対象とします。 一部の入力パラメーターを受け取る場合と受け取らない場合があり、一部の値を返す場合と返さない場合があります。
  • 統合テスト。個々のユニットを一緒にテストして、すべてのユニットが期待どおりに相互作用するかどうかを確認します。

すべてのプログラミング言語で利用できるフレームワークは多数あります。 JavaのSpringフレームワークで記述されたWebアプリのユニットの記述と統合テストに焦点を当てます。

ほとんどの場合、クラスにメソッドを記述し、これらは他のクラスのメソッドと相互作用します。 今日の世界、特にエンタープライズアプリケーションでは、アプリケーションの複雑さにより、1つのメソッドが複数のクラスの複数のメソッドを呼び出す可能性があります。 したがって、そのようなメソッドの単体テストを作成するときは、それらの呼び出しからモックデータを返す方法が必要です。 これは、この単体テストの目的が1つのメソッドのみをテストすることであり、この特定のメソッドが行うすべての呼び出しをテストすることではないためです。


JUnitフレームワークを使用してSpringでのJavaユニットテストに飛び込みましょう。 聞いたことがあるかもしれない何かから始めましょう:モック。

モックとは何ですか?それはいつ登場しますか?

与えられたタイプ(円、正方形、または長方形)の形状の面積を計算する関数calculateArea(Type type, Double... args)を持つクラスCalculateAreaがあるとします。

依存性注入を使用しない通常のアプリケーションでは、コードは次のようになります。

 public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { switch (type) { case RECTANGLE: if(r.length >=2) return rectangleService.area(r[0],r[1]); else throw new RuntimeException("Missing required params"); case SQUARE: if(r.length >=1) return squareService.area(r[0]); else throw new RuntimeException("Missing required param"); case CIRCLE: if(r.length >=1) return circleService.area(r[0]); else throw new RuntimeException("Missing required param"); default: throw new RuntimeException("Operation not supported"); } } }
 public class SquareService { public Double area(double r) { return r * r; } }
 public class RectangleService { public Double area(Double r, Double h) { return r * h; } }
 public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
 public enum Type { RECTANGLE,SQUARE,CIRCLE; }

ここで、 CalculateAreaクラスの関数calculateArea()を単体テストする場合、その動機は、 switchケースと例外条件が機能するかどうかを確認することです。 シェイプサービスが正しい値を返しているかどうかをテストするべきではありません。前述のように、関数の単体テストの目的は、関数が行っている呼び出しのロジックではなく、関数のロジックをテストすることです。

したがって、個々のサービス関数(たとえば、 rectangleService.area()によって返される値をモックし、それらのモックされた値に基づいて呼び出し元の関数(たとえば、 CalculateArea.calculateArea() )をテストします。

長方形サービスの単純なテストケースcalculateArea()が実際に正しいパラメーターを使用してrectangleService.area()を呼び出すことをテストする)は、次のようになります。

 import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; public class CalculateAreaTest { RectangleService rectangleService; SquareService squareService; CircleService circleService; CalculateArea calculateArea; @Before public void init() { rectangleService = Mockito.mock(RectangleService.class); squareService = Mockito.mock(SquareService.class); circleService = Mockito.mock(CircleService.class); calculateArea = new CalculateArea(squareService,rectangleService,circleService); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

ここで注意すべき2つの主要な行は次のとおりです。

  • rectangleService = Mockito.mock(RectangleService.class); -これにより、モックが作成されます。これは実際のオブジェクトではなく、モックされたオブジェクトです。
  • Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); -これは、モックされたときに、 rectangleServiceオブジェクトのareaメソッドが指定されたパラメーターで呼び出された場合、 20dを返すことを意味します。

さて、上記のコードがSpringアプリケーションの一部である場合はどうなりますか?

 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; public CalculateArea(@Autowired SquareService squareService, @Autowired RectangleService rectangeService, @Autowired CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }

ここでは、コンテキストの初期化時に検出する、基盤となるSpringフレームワークの2つのアノテーションがあります。

  • @Component :タイプCalculateAreaのBeanを作成します
  • @Autowired :BeanのrectangleServicesquareServicecircleServiceを検索し、それらをBeanのcalculatedAreaに挿入します。

同様に、他のクラスのBeanも作成します。

 import org.springframework.stereotype.Service; @Service public class SquareService { public Double area(double r) { return r*r; } }
 import org.springframework.stereotype.Service; @Service public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
 import org.springframework.stereotype.Service; @Service public class RectangleService { public Double area(Double r, Double h) { return r*h; } }

ここでテストを実行すると、結果は同じになります。 ここではコンストラクタインジェクションを使用しましたが、幸い、テストケースを変更しないでください。

ただし、正方形、円、および長方形のサービスのBeanを注入する別の方法があります。フィールド注入です。 これを使用する場合、テストケースにいくつかの小さな変更が必要になります。

記事の範囲外であるため、どのインジェクションメカニズムが優れているかについては説明しません。 しかし、私たちはこれを言うことができます:Beanを注入するために使用するメカニズムのタイプに関係なく、それに対するJUnitテストを作成する方法は常にあります。

フィールドインジェクションの場合、コードは次のようになります。

 @Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }

注:フィールドインジェクションを使用しているため、パラメーター化されたコンストラクターは必要ありません。オブジェクトはデフォルトのコンストラクターを使用して作成され、値はフィールドインジェクションメカニズムを使用して設定されます。

サービスクラスのコードは上記と同じですが、テストクラスのコードは次のとおりです。

 public class CalculateAreaTest { @Mock RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Before public void init() { MockitoAnnotations.initMocks(this); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

ここではいくつかの点が異なります。基本ではなく、それを達成する方法です。

まず、オブジェクトをモックする方法: @MockアノテーションとinitMocks()を使用してモックを作成します。 次に、 @InjectMocksinitMocks()を使用して、実際のオブジェクトにモックを注入します。

これは、コードの行数を減らすために行われます。

テストランナーとは何ですか?また、どのような種類のランナーがいますか?

上記のサンプルでは、​​すべてのテストを実行するために使用される基本的なランナーは、すべてのアノテーションを検出し、それに応じてすべてのテストを実行するBlockJUnit4ClassRunnerです。

さらに機能が必要な場合は、カスタムランナーを作成できます。 たとえば、上記のテストクラスで、 MockitoAnnotations.initMocks(this);という行をスキップする場合。 次に、 BlockJUnit4ClassRunnerの上に構築された別のランナー( MockitoJUnitRunnerなど)を使用できます。

MockitoJUnitRunnerを使用すると、モックを初期化して注入する必要さえありません。 これは、注釈を読み取るだけでMockitoJUnitRunner自体によって実行されます。

(Spring統合テストに必要なApplicationContextを初期化するSpringJUnit4ClassRunnerもあります。これは、Springアプリケーションの起動時にApplicationContextが作成されるのと同じです。これについては、後で説明します。)

部分的なモッキング

テストクラスのオブジェクトでいくつかのメソッドをモックするだけでなく、実際のメソッドを呼び出す場合は、部分的なモックが必要です。 これは、JUnitの@Spyを介して実現されます。

@Mockを使用するのとは異なり、 @Spy Spyを使用すると、実際のオブジェクトが作成されますが、そのオブジェクトのメソッドは、必要に応じてモックまたは実際に呼び出すことができます。

たとえば、クラスRectangleServiceareaメソッドが追加のメソッドlog()を呼び出し、実際にそのログを出力したい場合、コードは次のように変更されます。

 @Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }

rectangleService@Mockアノテーションを@Spyに変更し、以下に示すようにコードを変更すると、結果では実際にログが出力されますが、メソッドarea()はモックされます。 つまり、元の関数はその副作用のためだけに実行されます。 その戻り値は、モックされた値に置き換えられます。

 @RunWith(MockitoJUnitRunner.class) public class CalculateAreaTest { @Spy RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Test public void calculateRectangleAreaTest() { Mockito.doCallRealMethod().when(rectangleService).log(); Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

ControllerまたはRequestHandlerのテストはどのように行いますか?

上記で学んだことから、この例のコントローラーのテストコードは次のようになります。

 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AreaController { @Autowired CalculateArea calculateArea; @RequestMapping(value = "api/area", method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam("type") String type, @RequestParam("param1") String param1, @RequestParam(value = "param2", required = false) String param2 ) { try { Double area = calculateArea.calculateArea( Type.valueOf(type), Double.parseDouble(param1), Double.parseDouble(param2) ); return new ResponseEntity(area, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity(e.getCause(), HttpStatus.INTERNAL_SERVER_ERROR); } } }
 import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @RunWith(MockitoJUnitRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; @Test public void calculateAreaTest() { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); ResponseEntity responseEntity = areaController.calculateArea("RECTANGLE", "5", "4"); Assert.assertEquals(HttpStatus.OK,responseEntity.getStatusCode()); Assert.assertEquals(20d,responseEntity.getBody()); } }

上記のコントローラーテストコードを見ると、正常に機能しますが、基本的な問題が1つあります。それは、実際のAPI呼び出しではなく、メソッド呼び出しのみをテストすることです。 APIパラメータとAPI呼び出しのステータスをさまざまな入力についてテストする必要があるすべてのテストケースがありません。

このコードは優れています:

 import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

ここでは、 MockMvcが実際のAPI呼び出しを実行する方法を確認できます。 また、 status()content()など、コンテンツの検証を容易にする特別なマッチャーもあります。

JUnitとモックを使用したJava統合テスト

コードの個々のユニットが機能することがわかったので、それらが期待どおりに相互作用することを確認しましょう。

まず、すべてのBeanをインスタンス化する必要があります。これは、アプリケーションの起動時にSpringコンテキストの初期化時に発生するのと同じことです。

このために、クラス内のすべてのBeanを定義します。たとえば、 TestConfig.java

 import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TestConfig { @Bean public AreaController areaController() { return new AreaController(); } @Bean public CalculateArea calculateArea() { return new CalculateArea(); } @Bean public RectangleService rectangleService() { return new RectangleService(); } @Bean public SquareService squareService() { return new SquareService(); } @Bean public CircleService circleService() { return new CircleService(); } }

次に、このクラスをどのように使用し、統合テストを作成するかを見てみましょう。

 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {TestConfig.class}) public class AreaControllerIntegrationTest { @Autowired AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

ここでいくつかの変更があります。

  • @ContextConfiguration(classes = {TestConfig.class}) -これは、すべてのBean定義が存在するテストケースを示します。
  • @InjectMocksの代わりに、次を使用します。
 @Autowired AreaController areaController;

他のすべては同じままです。 テストをデバッグすると、 RectangleServicearea()メソッドの最後の行までコードが実際に実行され、 return r*hが計算されることがわかります。 つまり、実際のビジネスロジックが実行されます。

これは、統合テストで使用可能なメソッド呼び出しまたはデータベース呼び出しのモックがないことを意味するものではありません。 上記の例では、サードパーティのサービスやデータベースが使用されていないため、モックを使用する必要はありませんでした。 実生活では、このようなアプリケーションはまれであり、データベースまたはサードパーティのAPI、あるいはその両方にアクセスすることがよくあります。 その場合、 TestConfigクラスでBeanを作成するときに、実際のオブジェクトではなく、モックされたオブジェクトを作成し、必要に応じて使用します。

ボーナス:ラージオブジェクトテストデータを作成する方法

多くの場合、バックエンド開発者がユニットテストや統合テストを作成するのを妨げるのは、すべてのテスト用に準備する必要のあるテストデータです。

通常、データが十分に小さく、1つまたは2つの変数がある場合は、テストデータクラスのオブジェクトを作成していくつかの値を割り当てるのは簡単です。

たとえば、モックされたオブジェクトが別のオブジェクトを返すことを期待している場合、モックされたオブジェクトで関数が呼び出されると、次のようになります。

 Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);

そして、このオブジェクトを使用するには、次のようにします。

 Mockito.when(service.method(arguments...)).thenReturn(object);

これは上記のJUnitの例では問題ありませんが、上記のClass1クラスのメンバー変数が増え続けると、個々のフィールドを設定するのが非常に面倒になります。 クラスに別の非プリミティブクラスメンバーが定義されている場合もあります。 次に、そのクラスのオブジェクトを作成し、個々の必須フィールドを設定すると、定型文を作成するためだけに開発作業がさらに増加し​​ます。

解決策は、上記のクラスのJSONスキーマを生成し、対応するデータをJSONファイルに1回追加することです。 Class1オブジェクトを作成するテストクラスでは、オブジェクトを手動で作成する必要はありません。 代わりに、JSONファイルを読み取り、 ObjectMapperを使用して、必要なClass1クラスにマップします。

 ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );

これは、JSONファイルを作成してそれに値を追加する1回限りの作業です。 その後の新しいテストでは、新しいテストのニーズに応じてフィールドが変更されたJSONファイルのコピーを使用できます。

JUnitの基本:複数のアプローチと移転可能なスキル

Beanの注入方法に応じて、Javaユニットテストを作成する方法がたくさんあることは明らかです。 残念ながら、このトピックに関するほとんどの記事は1つの方法しかないと想定する傾向があるため、特に別の想定で記述されたコードを操作する場合は、混乱しやすくなります。 うまくいけば、ここでのアプローチにより、開発者がモックを作成する正しい方法と、使用するテストランナーを見つける時間を節約できます。

使用する言語やフレームワークに関係なく、おそらくSpringやJUnitの新しいバージョンでも、概念のベースは上記のJUnitチュートリアルで説明したものと同じです。 ハッピーテスト!