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.
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:
@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:
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:
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:
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.
@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:
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:
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.
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.
@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.