Mocking
Sissejuhatus
Üksustestid peaksid kontrollima ühte koodiosa isoleerituna ülejäänud koodist. Kuid päris kood töötab harva omaette üksusena - teenusklass kasutab andmehoidlat, kontroller kasutab teenusklassi, meetod loeb andmebaasist.
Kui näiteks klassi BookService testi jaoks on vaja päris andmebaasiühendust, ei ole see enam ühiktest - sellest saab integratsioonitest, mis on aeglane, habras ja sõltub välisest infrastruktuurist.
Mock'imine (immiteerimine) lahendab selle, asendades päris sõltuvused kontrollitud asendustega, mis käituvad täpselt nii, nagu test seda vajab.
Probleemist lähemalt
Võtame näiteks eelnevalt mainitud BookService klassi, mis sõltub BookRepository liidesest:
public class BookService {
private final BookRepository repository;
public BookService(BookRepository repository) {
this.repository = repository;
}
public String getAllBooks() {
return repository.findAll().toString();
}
}
BookRepository on liides ehk sellel puudub konkreetne implementatsioon.
Isegi selle olemasolul ei peaks konkreetne üksustest antud juhul tegelema andmebaasiühenduse loomisega, andmete sisestamisega ning peale testi puhastustöödega tegelema.
Selle kõige asemel on võimalik seda aspekti koodist immiteerida, luues võlts teostuse, mida on võimalik kontrollida:
BookRepository repository = mock(BookRepository.class);
Sedasi luuakse objekt, mis teostab BookRepository liidest, kuid millel puudub igasugune reaalne loogika.
Vaikimisi kõik meetodid tagastavad tühiväärtusi (null, 0 või tühjad kollektsioonid).
Seda käitumist on võimalik käsitsi muuta, andes ette mida mõni meetod peaks teatud sisendite puhul tagastama.
Mockito
Mockito on kõige laialdasemalt kasutatav immiteerimise teek Javas. See pakub lihtsat API-t mock-objektide loomiseks, nende käitumise määratlemiseks ning kontrollimiseks, et neid kutsuti õigesti.
Mockito lisamiseks projekti vaata väliste teekide lisamise juhendit.
Mock-objekti loomine
import static org.mockito.Mockito.*;
BookRepository repository = mock(BookRepository.class);
Selle tulemusena tekib mock-objekt, mis teostab BookRepository liidest.
See ei sisalda endas ei mingit päris andmebaasi ega tegelikku loogikat - ainult programmeeritav asendus.
Käitumise määratlemine when().thenReturn() kaudu
when().thenReturn() muster ütleb mock-objektile, mida tagastada, kui kutsutakse mõnda konkreetset meetodit:
Book book = new Book("Clean Code", "Robert Martin", 2008, new BigDecimal("35"));
when(repository.findAll()).thenReturn(List.of(book));
Nüüd iga kord, kui kutsutakse repository.findAll(), tagastab see nimekirja, mis sisaldab seda ühte raamatut.
Iga meetod, mille käitumust pole määratletud, tagastab vaikimisi väärtuse (null objektide puhul, 0 arvude puhul, false tõeväärtuste puhul, tühjad kogumikud kollektsioonitüüpide puhul).
Samuti on võimalik käitumist määratleda ka spetsiifilise sisendi jaoks:
when(repository.findByAuthor("Robert Martin")).thenReturn(List.of(cleanCode));
when(repository.findByAuthor("Martin Fowler")).thenReturn(List.of(refactoring));
when(repository.existsByTitleAndAuthor("Clean Code", "Robert Martin")).thenReturn(true);
Näidistest Mockitot kasutades
@Test
void testGetAllBooks_returnsAllBooksAsString() {
// Arrange
BookRepository repository = mock(BookRepository.class);
BookService service = new BookService(repository);
Book book = new Book("Clean Code", "Robert Martin", 2008, new BigDecimal("35"));
when(repository.findAll()).thenReturn(List.of(book));
// Act
String result = service.getAllBooks();
// Assert
assertEquals(List.of(book).toString(), result);
}
Testis luuakse võlts andmehoidla, sellele määratakse kindel käitumine ning edastatakse teenusklassile, mille tulemust lõpuks omakorda kontrollitakse.
BookService klass ei tea ega hooli sellest, et andmehoidlat tegelikult pole olemas - antud juhul see kutsub selle meetodeid välja ja saab mingi tulemuse vastu.
Teenusklassi vaatenurgast kõik sellele vajalikud sõltuvused toimivad.
Siin tuleb mängu ka sõltuvuste süstimise mõiste.
Kuna BookService saab andmehoidla kätte konstruktori kaudu, siis selle asendamine testides on triviaalne.
Meetodikutsede kontrollimine verify() kaudu
Mõnikord on vaja kontrollida mitte ainult väljundit, vaid ka seda, et mock-objektil kutsuti konkreetseid meetodeid. See on kasulik juhul, kui testitav meetod ei tagasta väärtust (nt kustutamisoperatsioonid).
@Test
void testDeleteBook_bookExists_callsRepositoryDelete() {
BookRepository repository = mock(BookRepository.class);
BookService service = new BookService(repository);
when(repository.existsByTitleAndAuthor("Clean Code", "Robert Martin")).thenReturn(true);
when(repository.deleteByTitleAndAuthor("Clean Code", "Robert Martin")).thenReturn(true);
service.deleteBook("Clean Code", "Robert Martin");
// Verify that the repository's delete method was actually called
verify(repository, times(1)).deleteByTitleAndAuthor("Clean Code", "Robert Martin");
}
Kontrollmeetodite näited:
verify(mock, times(1)).method(); // called exactly once
verify(mock, times(3)).method(); // called exactly 3 times
verify(mock, atLeastOnce()).method(); // called 1 or more times
verify(mock, never()).method(); // never called
Erinditega tegelemine
Immitatsioone saab seadistada ka erindeid viskama:
when(repository.deleteByTitleAndAuthor("Clean Code", "Robert Martin"))
.thenThrow(new RuntimeException("Database error"));
Millal kasutada immiteerimist
Immiteeri väliseid sõltuvusi, mida sinu kood kasutab. Sinna hulka kuuluvad näiteks:
- Andmehoidla/andmebaasi ligipääs
- Välised API teenused
- E-posti teenused, failisüsteemid jne.
Samuti tasuks kaaluda immiteerimist, kui:
- testitav komponent on aeglane ja sõltub välistest faktoritest
- sõltuvused pole deterministlikud (iga kord erinevad väärtused, raske korrata)
- on vaja simuleerida veaseisundeid (nt erindid, timeout'id, vigased vastused)
- meetod ei tagasta väärtust, vaid tekitab kõrvalmõjusid (nt e-kirja saatmine, faili kirjutamine)
- soovid isoleerida äriloogikat teistest kihtidest (nt andmehoidlast või API-st)
- päris sõltuvuse seadistamine on keeruline või ajamahukas
- on vaja testida haruldasi või raskesti taasesitatavaid olukordi
Ära immiteeri:
- Klassi, mida sa testid.
- Lihtsaid väärtusobjekte või andmeklasse (Book, String, List).
- Integratsiooniteste - need kasutavad reaalseid komponente
Lühidalt, kõike ei tasu immiteerida. Kui sa kasutad mock'imist iga objekti jaoks, siis see on märk sellest, et rakendus kasutab liiga palju sõltuvusi, mida on raske hallata.
mock'i asju, millega sinu klass suhtleb, mitte neid, mis sinu klass on või mida ta sisemiselt kasutab.
Kui meetod võtab sisendina String-i ja tagastab int-i, pole vaja midagi mock'ida - lihtsalt kutsu seda.
Terviklik näide
Siin on terviklik BookService klassi testimise näidis, mis kasutab JUnit 5 ja Mockitot.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class BookServiceTest {
private BookRepository repository;
private BookService service;
@BeforeEach
void setup() {
repository = mock(BookRepository.class);
service = new BookService(repository);
}
@Test
void testGetAllBooks_returnsAllBooksAsString() {
Book book = new Book("Refactoring", "Martin Fowler", 1999, new BigDecimal("40"));
when(repository.findAll()).thenReturn(List.of(book));
String result = service.getAllBooks();
assertEquals(List.of(book).toString(), result);
}
@Test
void testFindBooksByAuthor_returnsMatchingBooks() {
Book book = new Book("Clean Code", "Robert Martin", 2008, new BigDecimal("35"));
when(repository.findByAuthor("Robert Martin")).thenReturn(List.of(book));
String result = service.findBooksByAuthor("Robert Martin");
assertEquals(List.of(book).toString(), result);
}
@Test
void testGetBook_bookNotFound_returnsNotFoundMessage() {
when(repository.existsByTitleAndAuthor("Unknown", "Nobody")).thenReturn(false);
String result = service.getBook("Unknown", "Nobody");
assertEquals("Book not found", result);
verify(repository, never()).getByTitleAndAuthor("Unknown", "Nobody");
}
@Test
void testDeleteBook_bookExists_deletionSucceeds() {
when(repository.existsByTitleAndAuthor("Clean Code", "Robert Martin")).thenReturn(true);
when(repository.deleteByTitleAndAuthor("Clean Code", "Robert Martin")).thenReturn(true);
String result = service.deleteBook("Clean Code", "Robert Martin");
assertEquals("Book was successfully deleted", result);
verify(repository, times(1)).deleteByTitleAndAuthor("Clean Code", "Robert Martin");
}
@Test
void testDeleteBook_bookExists_deletionFails() {
when(repository.existsByTitleAndAuthor("Clean Code", "Robert Martin")).thenReturn(true);
when(repository.deleteByTitleAndAuthor("Clean Code", "Robert Martin")).thenReturn(false);
String result = service.deleteBook("Clean Code", "Robert Martin");
assertEquals("Error while deleting book", result);
}
@Test
void testFindBooksInPriceRange_invalidArguments_returnsErrorMessage() {
String result = service.findBooksInPriceRange("abc", "100");
assertEquals("Invalid arguments", result);
}
}
Iga test on iseseisev, fokuseeritud ühe kindla käitumise ümber, järgib AAA mustrit ning immiteerib ainult andmehoidlat.