Güvenle Oluşturun: JUnit Testlerine Yönelik Bir Kılavuz

Yayınlanan: 2022-03-11

Teknolojinin ve endüstrinin şelale modelinden Çevik'e ve şimdi DevOps'a geçmesiyle birlikte, bir uygulamadaki değişiklikler ve geliştirmeler yapıldıkları anda üretime alınır. Kodun üretime bu kadar hızlı dağıtılmasıyla, değişikliklerimizin çalıştığından ve ayrıca önceden var olan herhangi bir işlevi bozmadığından emin olmamız gerekiyor.

Bu güveni oluşturmak için otomatik regresyon testi için bir çerçeveye sahip olmamız gerekir. Regresyon testi yapmak için API düzeyinde bir bakış açısıyla yapılması gereken birçok test vardır, ancak burada iki ana test türünü ele alacağız:

  • Herhangi bir testin bir programın en küçük birimini (bir fonksiyon veya prosedür) kapsadığı birim testi. Bazı girdi parametrelerini alabilir veya almayabilir ve bazı değerleri döndürebilir veya döndürmeyebilir.
  • Tüm birimlerin beklendiği gibi birbirleriyle etkileşime girip girmediğini kontrol etmek için ayrı birimlerin birlikte test edildiği entegrasyon testi .

Her programlama dili için çok sayıda çerçeve mevcuttur. Java'nın Spring çerçevesinde yazılmış bir web uygulaması için birim yazmaya ve entegrasyon testine odaklanacağız.

Çoğu zaman, bir sınıfa yöntemler yazarız ve bunlar da başka bir sınıfın yöntemleriyle etkileşime girer. Günümüz dünyasında - özellikle kurumsal uygulamalarda - uygulamaların karmaşıklığı, tek bir yöntemin birden fazla sınıfın birden fazla yöntemini çağırabilmesini sağlayacak şekildedir. Bu nedenle, böyle bir yöntem için birim testi yazarken, bu çağrılardan sahte verileri döndürmenin bir yoluna ihtiyacımız var. Bunun nedeni, bu birim testinin amacının, bu belirli yöntemin yaptığı tüm çağrıları değil, yalnızca bir yöntemi test etmektir.


JUnit çerçevesini kullanarak İlkbaharda Java birim testine geçelim. Duymuş olabileceğiniz bir şeyle başlayacağız: alay etmek.

Alay Nedir ve Resme Ne Zaman Gelir?

CalculateArea adlı bir sınıfınız olduğunu ve verilen türdeki (daire, kare veya dikdörtgen) bir şeklin alanını hesaplayan calculateArea(Type type, Double... args) işlevine sahip olduğunuzu varsayalım.

Kod, bağımlılık enjeksiyonunu kullanmayan normal bir uygulamada şöyle bir şey olur:

 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; }

Şimdi, CalculateArea sınıfının hesapArea calculateArea() işlevini birim test etmek istiyorsak, o zaman amacımız, switch durumlarının ve istisna koşullarının çalışıp çalışmadığını kontrol etmek olmalıdır. Şekil hizmetlerinin doğru değerleri döndürüp döndürmediğini test etmemeliyiz, çünkü daha önce belirtildiği gibi, bir işlevi birim test etmenin amacı, işlevin yaptığı çağrıların mantığını değil, işlevin mantığını test etmektir.

Bu nedenle, bireysel hizmet işlevleri (örneğin rectangleService.area() tarafından döndürülen değerlerle alay edeceğiz ve bu alay edilen değerlere dayalı olarak çağıran işlevi (örneğin CalculateArea.calculateArea() ) test edeceğiz.

Dikdörtgen hizmeti için basit bir test senaryosu calculateArea() öğesinin gerçekten doğru parametrelerle rectangleService.area() öğesini çağırdığını test etmek) şöyle görünür:

 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); } }

Burada dikkat edilmesi gereken iki ana satır şunlardır:

  • rectangleService = Mockito.mock(RectangleService.class); —Bu, gerçek bir nesne değil, alay konusu olan bir sahte oluşturur.
  • Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); —Bu, alay edildiğinde ve rectangleService nesnesinin area yönteminin belirtilen parametrelerle çağrıldığını ve ardından 20d döndürdüğünü söylüyor.

Şimdi, yukarıdaki kod bir Spring uygulamasının parçası olduğunda ne olur?

 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) } }

Burada, bağlam başlatma sırasında algılamak için temeldeki Spring çerçevesi için iki ek açıklamamız var:

  • @Component : CalculateArea türünde bir fasulye oluşturur
  • @Autowired : bean rectangleService , squareService ve circleService ve bunları fasulye calculatedArea enjekte eder

Benzer şekilde, diğer sınıflar için de fasulye oluşturuyoruz:

 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; } }

Şimdi testleri yaparsak, sonuçlar aynı. Burada yapıcı enjeksiyon kullandık ve neyse ki test durumumuzu değiştirmedik.

Ancak kare, daire ve dikdörtgen servislerinin çekirdeklerini enjekte etmenin başka bir yolu daha var: alan enjeksiyonu. Bunu kullanırsak, test durumumuzun bazı küçük değişikliklere ihtiyacı olacaktır.

Hangi enjeksiyon mekanizmasının daha iyi olduğu tartışmasına girmeyeceğiz, çünkü bu makalenin kapsamında değil. Ancak şunu söyleyebiliriz: Fasulyeyi enjekte etmek için ne tür bir mekanizma kullanırsanız kullanın, bunun için JUnit testleri yazmanın her zaman bir yolu vardır.

Alan enjeksiyonu durumunda, kod şuna benzer:

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

Not: Alan enjeksiyonunu kullandığımız için parametreli bir kurucuya gerek yoktur, bu nedenle nesne varsayılanı kullanılarak oluşturulur ve değerler alan enjeksiyon mekanizması kullanılarak ayarlanır.

Hizmet sınıflarımızın kodu yukarıdakiyle aynı kalır, ancak test sınıfının kodu aşağıdaki gibidir:

 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); } }

Burada birkaç şey farklı gidiyor: temeller değil, bunu başarma şeklimiz.

İlk olarak, nesnelerimizle alay etme şeklimiz: Sahte oluşturmak için initMocks() ile birlikte @Mock açıklamalarını kullanırız. İkinci olarak, initMocks() ile birlikte @InjectMocks kullanarak gerçek nesneye alaylar enjekte ederiz.

Bu sadece kod satırı sayısını azaltmak için yapılır.

Test Koşucuları Nedir ve Ne Tür Koşucular Vardır?

Yukarıdaki örnekte, tüm testleri çalıştırmak için kullanılan temel koşucu, tüm açıklamaları algılayan ve tüm testleri buna göre çalıştıran BlockJUnit4ClassRunner .

Biraz daha işlevsellik istiyorsak özel bir koşucu yazabiliriz. Örneğin yukarıdaki test sınıfında MockitoAnnotations.initMocks(this); o zaman BlockJUnit4ClassRunner üzerine kurulu farklı bir koşucu kullanabiliriz, örneğin MockitoJUnitRunner .

MockitoJUnitRunner kullanarak, alayları başlatmamıza ve onları enjekte etmemize bile gerek yok. Bu, MockitoJUnitRunner tarafından yalnızca ek açıklamaları okuyarak yapılacaktır.

(Ayrıca Spring entegrasyonu testi için gereken ApplicationContext SpringJUnit4ClassRunner da var - tıpkı bir Spring uygulaması başladığında bir ApplicationContext oluşturulması gibi. Bunu daha sonra ele alacağız.)

Kısmi Alay

Test sınıfındaki bir nesnenin bazı yöntemlerle/yöntemlerle alay etmesini ve aynı zamanda bazı gerçek yöntem(ler)i de çağırmasını istediğimizde, o zaman kısmi alaya ihtiyacımız olur. Bu, @Spy aracılığıyla elde edilir.

@Mock @Spy gerçek bir nesne oluşturulur, ancak bu nesnenin yöntemleriyle alay edilebilir veya gerçekten çağrılabilir - neye ihtiyacımız varsa.

Örneğin, RectangleService sınıfındaki area yöntemi fazladan bir log() yöntemini çağırırsa ve biz aslında bu günlüğü yazdırmak istersek, kod aşağıdaki gibi bir şeye dönüşür:

 @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 ek açıklamasını @Spy olarak değiştirirsek ve ayrıca aşağıda gösterildiği gibi bazı kod değişiklikleri yaparsak, sonuçlarda aslında günlüklerin yazdırıldığını görürüz, ancak area() yöntemiyle alay edilir. Yani, orijinal işlev yalnızca yan etkileri için çalıştırılır; dönüş değerleri alaylı değerlerle değiştirilir.

 @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); } }

Bir Controller veya RequestHandler Test Etme Konusunda Nasıl Gidiyoruz?

Yukarıda öğrendiklerimize göre, örneğimiz için bir kontrolörün test kodu aşağıdaki gibi olacaktır:

 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()); } }

Yukarıdaki denetleyici test koduna bakıldığında, iyi çalışıyor, ancak temel bir sorunu var: Gerçek API çağrısını değil, yalnızca yöntem çağrısını test ediyor. API parametrelerinin ve API çağrılarının durumunun farklı girdiler için test edilmesi gereken tüm bu test durumları eksik.

Bu kod daha iyi:

 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")); } }

Burada MockMvc gerçek API çağrıları yapma işini nasıl üstlendiğini görebiliriz. Ayrıca, status() ve content() gibi içeriği doğrulamayı kolaylaştıran bazı özel eşleştiricilere de sahiptir.

JUnit ve Mocks Kullanarak Java Entegrasyon Testi

Artık kodun bireysel birimlerini bildiğimize göre, beklediğimiz gibi birbirleriyle etkileşime girdiğinden emin olalım.

İlk olarak, uygulama başlatma sırasında Spring bağlamı başlatma sırasında gerçekleşen aynı şeyler olan tüm fasulyeleri başlatmamız gerekiyor.

Bunun için bir sınıftaki tüm fasulyeleri tanımlıyoruz, diyelim ki 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(); } }

Şimdi bu sınıfı nasıl kullandığımızı görelim ve bir entegrasyon testi yazalım:

 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")); } }

Burada birkaç şey değişiyor:

  • @ContextConfiguration(classes = {TestConfig.class}) — bu, test senaryosuna tüm fasulye tanımlarının nerede olduğunu söyler.
  • Şimdi @InjectMocks yerine şunu kullanıyoruz:
 @Autowired AreaController areaController;

Diğer her şey aynı kalır. Testin hatalarını ayıklarsak, kodun gerçekte return r*h hesaplandığı RectangleService içindeki area() yönteminin son satırına kadar çalıştığını görürüz. Başka bir deyişle, gerçek iş mantığı çalışır.

Bu, entegrasyon testinde yöntem çağrılarının veya veritabanı çağrılarının alay konusu olmadığı anlamına gelmez. Yukarıdaki örnekte, kullanılan hiçbir üçüncü taraf hizmeti veya veritabanı yoktu, dolayısıyla sahte kullanmamıza gerek yoktu. Gerçek hayatta, bu tür uygulamalar nadirdir ve genellikle bir veritabanına veya üçüncü taraf API'sine veya her ikisine birden ulaşırız. Bu durumda, TestConfig sınıfında oluşturduğumuzda, asıl nesneyi değil, alaylı bir nesneyi yaratır ve gerektiğinde kullanırız.

Bonus: Büyük Nesne Test Verileri Nasıl Oluşturulur

Genellikle arka uç geliştiricilerini birim veya entegrasyon testleri yazma konusunda durduran şey, her test için hazırlamamız gereken test verileridir.

Normalde veriler yeterince küçükse, bir veya iki değişkene sahipse, o zaman bir test veri sınıfının nesnesini oluşturmak ve bazı değerler atamak kolaydır.

Örneğin, alaylı bir nesnenin başka bir nesne döndürmesini bekliyorsak, alaylı nesne üzerinde bir işlev çağrıldığında şöyle bir şey yapardık:

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

Ve sonra bu nesneyi kullanmak için şöyle bir şey yapardık:

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

Bu, yukarıdaki JUnit örneklerinde iyidir, ancak yukarıdaki Class1 sınıfındaki üye değişkenler artmaya devam ettiğinde, bireysel alanları ayarlamak oldukça zahmetli hale gelir. Bazen bir sınıfın tanımlanmış başka bir ilkel olmayan sınıf üyesi olması bile olabilir. Daha sonra, o sınıftan bir nesne yaratmak ve bireysel gerekli alanları ayarlamak, sadece bazı ortak kalıpları gerçekleştirmek için geliştirme çabasını daha da artırır.

Çözüm, yukarıdaki sınıfın bir JSON şemasını oluşturmak ve ilgili verileri JSON dosyasına bir kez eklemektir. Artık Class1 nesnesini oluşturduğumuz test sınıfında, nesneyi manuel olarak oluşturmamıza gerek yok. Bunun yerine, JSON dosyasını okuruz ve ObjectMapper kullanarak onu gerekli Class1 sınıfıyla eşleştiririz:

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

Bu, bir JSON dosyası oluşturmak ve ona değerler eklemek için tek seferlik bir çabadır. Bundan sonraki tüm yeni testler, alanları yeni testin ihtiyaçlarına göre değiştirilen JSON dosyasının bir kopyasını kullanabilir.

JUnit Temelleri: Çoklu Yaklaşımlar ve Aktarılabilir Beceriler

Fasulyeleri nasıl enjekte etmeyi seçtiğimize bağlı olarak Java birim testleri yazmanın birçok yolu olduğu açıktır. Ne yazık ki, konuyla ilgili çoğu makale yalnızca bir yol olduğunu varsayma eğilimindedir, bu nedenle özellikle farklı bir varsayım altında yazılmış kodlarla çalışırken kafanın karışması kolaydır. Umarız buradaki yaklaşımımız, geliştiricilere alay etmenin doğru yolunu ve hangi test çalıştırıcısını kullanacaklarını bulma konusunda zaman kazandırır.

Kullandığımız dil veya çerçeveden bağımsız olarak - belki de Spring veya JUnit'in herhangi bir yeni sürümü bile - kavramsal temel, yukarıdaki JUnit eğitiminde açıklandığı gibi kalır. Mutlu testler!