Jak przyspieszyć testy jednostkowe za pomocą Stub API

Jeśli kiedykolwiek mieliście okazję pracować z bazą kodu zawierającą więcej niż dziesięć klas, problemy związane z czasem wykonania Testów Jednostkowych są Wam zapewne dobrze znane. Choć ogólne działanie całego systemu musi i będzie się stopniowo poprawiać, w tym artykule spróbuję odpowiedzieć na pytanie, co my jako programiści możemy zrobić sami, żeby usprawnić swoją pracę.
Chcąc lepiej zrozumieć ten problem, posłużymy się przykładem. W tym celu utworzę metodę wywołaną w kontekście „before insert”, w triggerze podczas zapisu rekordu typu Contact. Jej głównym celem będzie ustawienie kilku pól w oparciu o nadrzędny rekord Account.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public with sharing class PropagateAccountDataForNewContacts(){ public void propagateAccountDataToChildContact(List<Contact> newContacts){ Set<Id> parentAccountIds = new Set<Id>(); for(Contact newContact : newContacts){ parentAccountIds.add(newContact.AccountId); } Map<Id, Account> parentAccountsMap = new Map<Id, Account> ([SELECT Id, Phone, ..., SomeExtraField__c FROM Account WHERE Id IN :parentAccountIds]); for(Contact newContact : newContacts){ setFieldsForNewContact(newContact, parentAccountsMap.get(newContact.AccountId)); } } } |
Spis treści
Test Jednostkowy
Zadaniem Testów Jednostkowych jest zagwarantowanie braku błędów w kodzie – poprzez sprawdzanie, czy części kodu działają bez problemów. W celu wdrożenia kodu w środowisku produkcyjnym, Testy Jednostkowe powinny objąć przynajmniej 75 procent kodu. Zazwyczaj proces tworzenia nowego Testu Jednostkowego jest dość prosty i składa się z następujących etapów:
- Tworzenia klasy testowej i/lub metody testowej.
- Tworzenia danych testowych.
- Wywoływania metody, którą chcemy przetestować.
- Oceny wyników.
Takie podejście dobrze się sprawdza w zarządzaniu małymi i średnimi projektami, ale może spowodować wiele problemów, gdy zwiększy się baza kodu. Standardowy Test Jednostkowy jest dość prosty w użyciu w następującym przykładzie:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@isTest static void testShouldPropagateAccountDataToNewContacts(){ Account a = TestUtil.createAccount(); insert a; Contact testContact = TestUtil.createContact(a.Id); Test.startTest(); insert testContact; Test.stopTest(); Contact resultContact = TestUtil.getContactById(testContact.Id); System.assertEquals(a.Phone, resultContact.Phone, 'Phone field should propagate from Account to Contact record.'); System.assertEquals(a.SomeExtraField__c, resultContact.SomeExtraField__c, 'SomeExtraField field should propagate from Account to Contact record.'); } |
Problemy z Testami Jednostkowymi
Każdy, kto stosuje w projekcie CI, dobrze wie, ile czasu potrzeba na wdrożenie produkcyjne. Czas wykonania wymagany w Testach Jednostkowych staje się problemem, zwłaszcza w przypadku większych zespołów – ze względu na duże wykorzystanie bazy danych.
W Salesforce tworzenie Testów Jednostkowych, które są faktycznie Testami Jednostkowymi, nie jest łatwe. W standardowym podejściu, zamiast Testów Jednostkowych, tworzone są Testy Systemowe. Ich głównym celem jest sprawdzenie ogólnej logiki, bez dzielenia kodu na wiele mniejszych części. W celu uzyskania faktycznych Testów Jednostkowych i skrócenia czasu wykonania testu, musimy zmienić sposób myślenia. Z testem w podanym przykładzie nie ma problemu, o ile test ten nie jest wykonywany na środowisku ze „154” deklaratywnymi narzędziami, które pracują z obiektem Contact.
Nowe podejście
Jeśli Test Jednostkowy ma zostać uznany za prawdziwy, musi zostać zbudowana warstwa dostępu do danych tylko do komunikowania się z bazą danych i zainicjowania nowego sposobu wykonywania testów – wszystko w pamięci. Pojawiają się przy tym następujące pytania:
- Jak zbudować dane testowe bez wykorzystania DML?
- Co, jeśli logika obejmuje SOQL?
- Jak ta dodatkowa warstwa wpłynie na pracę programistów oraz utrzymanie kodu?
Najważniejszym polem zapisu jest ID i jest to jeden z kluczowych powodów, dla których wykorzystujemy DML podczas konfiguracji testów. W budowaniu odpowiednich Testów Jednostkowych, baza danych nie jest wymagana do uzyskania rekordu z ID. Zamiast tego, można utworzyć rekord z ID.
W celu utworzenia ID zapisu, wymagany jest unikalny prefiks SObject, inaczej zostanie wygenerowany błąd. W celu odebrania odpowiedniego prefiksu, należy zastosować metodę klasy Schema:
sObjectType.getDescribe ().getKeyPrefix (), gdzie sObjectType może być pobrany z globalnego opisu lub z samej klasy SObject – Account. SObjectType.
Do tego celu może być wykorzystana prosta funkcja:
1 2 3 4 5 |
public static String getFakeId(Schema.SObjectType sObjectType){ String result = String.valueOf(fakeIdNumber++); return sObjectType.getDescribe().getKeyPrefix() + '0'.repeat(12-result.length()) + result; } |
Po utworzeniu rekordu z ID, ID może być wykorzystany w relacji typu lookup lub relacji typu master-detail – w celu utworzenia struktur danych wymaganych do wykonywania testów.
Wszelkie SOQL zawarte w logice powinny być przeniesione do odrębnej klasy w nowej warstwie, o nazwie Data Access Layer. Te typy klas będą naszym punktem kontaktu z bazą danych. Bazując na poniższym przykładzie, zamiast:
1 2 3 |
for(Contact contact : [SELECT Id, Name FROM Contact WHERE Id IN :contactIds]){ Dostuff(contact); } |
Ta dodatkowa warstwa zapewnia ekstra korzyść. Po pierwsze, łatwiej jest utrzymywać pojedynczy punkt kontaktu z bazą danych niż mieć zapytania rozsiane w całym kodzie. Po drugie, opisowe metody, które będą wyszukiwać dane, mogą być uproszczone, ułatwiając naukę osobom pracującym z tym kodem w przyszłości. Po trzecie wreszcie, istnieje możliwość symulowania wyników wyszukiwania w Testach Jednostkowych – tak, że logika biznesowa może zostać przetestowana kompleksowo bez większego wpływu na wykorzystanie zasobów.
W podanym przykładzie można zastosować prostą klasę, na przykład:
1 2 3 4 5 6 7 8 9 10 11 12 |
public with sharing class AccountDataAccess { public List<Account> getAccountsForGivenIds(Set<Id> accountIds){ if(accountIds.isEmpty()){ return [SELECT Id, Phone, ..., SomeExtraField__c FROM Account WHERE Id IN :accountIds]; } else { return new List<Account>(); } } } |
Stub API
Chcąc przyspieszyć wykonanie Testów Jednostkowych za pomocą Stub API, należy zbudować symulacyjną strukturę ramową w celu ‘zasymulowania’ bazy danych. Mogą już istnieć testy wykorzystujące technikę symulowania wyników, ponieważ jest to standardowy sposób testowania kodu, który wykorzystuje wywołania do zewnętrznych systemów.
Ogólna koncepcja jest podobna: symulowane wyniki innych zewnętrznych systemów są wykorzystywane w celu sprawdzenia oczekiwanych wyników względem logiki biznesowej. W tym scenariuszu systemem zewnętrznym jest baza danych. W ramach przygotowania tworzonych jest kilka klas. Pierwszą będzie MockProvider – ta klasa umożliwia użytkownikom symulowanie wszystkich metod w symulowanej klasie poprzez mapowanie.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@isTest public class MockProvider implements System.StubProvider { private Map<String, Object> stubbedMethodMap; public MockProvider(Map<String, Object> stubbedMethodMap) { this.stubbedMethodMap = stubbedMethodMap; } public Object handleMethodCall(Object stubbedObject, String stubbedMethodName, Type returnType, List<Type> listOfParamTypes, List<String> listOfParamNames, List<Object> listOfArgs) { Object result; if (stubbedMethodMap.containsKey(stubbedMethodName)) { result = stubbedMethodMap.get(stubbedMethodName); } return result; } } |
Następnym krokiem jest utworzenie klasy MockService, która będzie odpowiedzialna za symulowane wyniki funkcji w następujący sposób:
1 2 3 4 5 6 7 8 9 10 11 |
public class MockService { private MockService() {} public static MockProvider getInstance(Map<String, Object> stubbedMethodMap) { return new MockProvider (stubbedMethodMap); } public static Object createMock(Type typeToMock, Map<String, Object> stubbedMethodMap) { return Test.createStub(typeToMock, MockService.getInstance(stubbedMethodMap)); } } |
Jak widać, funkcja createMock jest wykorzystywana do utworzenia obiektu pozornego. Wykorzystanie Test.createStub wywołuje Stub API – system jest informowany, która klasa zostanie ‘zasymulowana’ oraz co zostanie zwrócone w przypadku określonych wywołań metod. Wykorzystanie tej usługi jest bardzo proste:
1 2 3 4 5 6 |
ClassToTest.dataAccessLayerClassInstance = (DataAccessLayerClass) MockService.createMock(DataAccessLayerClass.class, new Map<String, Object>{ 'getRecords' => resultForGetRecords, 'getChildRecords' => resultForGetChildRecords, 'updateRecords' => null }); |
Po wywołaniu metody
getRecords () z
dataAccessLayerClassInstance w ramach testowanej klasy, rezultatem będzie
resultForGetRecords.
We wspomnianym przykładzie muszą zostać dokonane pewne drobne korekty – wszystko w celu wykorzystania Stub API w testach. Zgodnie z powyższym należy zastosować klasę Data Access Layer zamiast wbudowanych SOQL.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public with sharing class PropagateAccountDataForNewContacts(){ @TestVisible private AccountDataAccess accountData = new AccountDataAccess(); public void propagateAccountDataToChildContact(List<Contact> newContacts){ Set<Id> parentAccountIds = new Set<Id>(); for(Contact newContact : newContacts){ parentAccountIds.add(newContact.AccountId); } Map<Id, Account> parentAccountsMap = new Map<Id, Account> (accountData.getAccountsForGivenIds(parentAccountIds)); for(Contact newContact : newContacts){ setFieldsForNewContact(newContact, parentAccountsMap.get(newContact.AccountId)); } } } |
Po wprowadzeniu zmian, wystarczy użyć klasy MockService w klasie Testu Jednostkowego i uzyskać szybkie wyniki, bez dotykania bazy danych.
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 |
@isTest static void testShouldPropagateAccountDataToNewContacts(){ Account mockedAccount = new Account( Id = TestUtil.getFakeId(Account.SObjectType), Name = 'Acme', Phone = TestUtil.generatePhone(), SomeExtraField__c = 'extraValue' ); Contact testContact = new Contact( Id = TestUtil.getFakeId(Contact.SObjectType); FirstName = 'John', LastName = 'Doe' ); PropagateAccountDataForNewContacts testedClass = new PropagateAccountDataForNewContacts(); testedClass.accountData = (AccountDataAccess) MockService.createMock (AccountDataAccess.class, new Map<String, Object> { 'getAccountsForGivenIds' => new List<Account>{mockedAccount} }); Test.startTest(); PropagateAccountDataForNewContacts.propagateAccountDataToChildContact (new List<Contact>{testContact}; Test.stopTest(); System.assertEquals(mockedAccount.Phone, testedClass.Phone, 'Phone field should propagate from Account to Contact record.'); System.assertEquals(mockedAccount.SomeExtraField__c, testedClass.SomeExtraField__c, 'SomeExtraField field should propagate from Account to Contact record.'); } |
Podsumowanie
Choć czas wykonania Testów Jednostkowych może być problematyczny, to dzięki zaprezentowanej w artykule metodzie, można znacząco przyspieszyć proces, a dzięki temu usprawnić swoją codzienną pracę.
Artykuł został pierwotnie opublikowany na stronie softserveinc.com. Zdjęcie główne artykułu pochodzi z unsplash.com.
Podobne artykuły

Jako twórcy aplikacji mało wiemy o odbiorcach. O użyteczności i dostępności w IT

Klienci chcą rozwiązań problemów, a nie fajerwerków. O zjawisku overengineeringu

Zmienił się apetyt na ryzyko. Organizacje w końcu kładą nacisk na budowę kultury jakości

Automatyzuj przewidywalną część pracy. Zaoszczędzony czas poświęć na dogłębną analizę kodu

Czy każdy programista powinien umieć testować? Devdebata

Testowanie oprogramowania krok po kroku. Zobacz, jak tworzymy strategię testowania

Za jakość odpowiada nie tylko dział QA, ale wszyscy członkowie zespołu. Wywiad z Radosławem Inczewskim
