Klasy testowe w języku programowania Apex

Klasy testowe w języku programowania Apex

Testy jednostkowe mają niemałe znacznie w przypadku każdego języka programowania i choć w wielu sytuacjach można byłoby się bez nich jednak obejść, to w przypadku platformy Salesforce chcąc wdrożyć kod aplikacji na środowisko produkcyjne musimy osiągnąć minimum 75% pokrycia kodu napisanego przy użyciu języka Apex. Nie oznacza to jednak, że testy powinny być pisane jedynie po to, by zapewnić odpowiednie pokrycie. Głównym zadaniem klas testowych powinno być przetestowanie tego, czy napisany przez nas kod faktycznie robi to, co robić powinien.

W przypadku języka Apex w zależności od tego, czy testujemy jakąś klasę typu utility, któryś z triggerów, klasę implementującą interfejs Database.Batchable, czy może jeszcze coś innego, do tworzenia metod testowych podchodzi się nieco inaczej, co postaram się przedstawić w tym artykule. Z pewnością warto jednak zacząć od tego, jak na ogół klasy napisane w języku Apex wyglądają, czym się charakteryzują, jakie mają ograniczenia.

Testy dla „prostych” klas

Załóżmy, że chcemy napisać klasę testową dla klasy, która posiada metody pozwalające na dodanie do siebie lub pomnożenie dwóch liczb. Adnotacja @TestVisible w poniższym przykładzie oznacza, że metoda prywatna będzie widoczna dla klas testowych.

public class MathUtility {
    public static Integer addNumbers(Integer a, Integer b) {
        return a + b;
    }

    public static Integer multiplyNumbers(Integer a, Integer b) {
        return a * b;
    }
    
    @TestVisible
    private static Boolean isEvenNumber(Integer a) {
        return Math.mod(a, 2) == 0;
    }
}

To co w przypadku takiej klasy testowej na pewno chcielibyśmy przetestować, to czy aby na pewno w poprawny sposób sumuje ona i mnoży dwie liczby.

@isTest
private class MathUtilityTest {
    @isTest
    static void testAddNumbers() {
        Integer result = MathUtility.addNumbers(2, 3);
        System.assertEquals(5, result, 'The expected result is 5.');
    }
    
    @isTest
    static void testMultiplyNumbers() {
        Integer result = MathUtility.multiplyNumbers(2, 3);
        System.assertEquals(6, result, 'The expected result is 6.');
    }
    
    @isTest
    static void testIsEvenNumber() {
        Boolean result = MathUtility.isEvenNumber(2);
        System.assert(true, 'The expected result is true.');
    }
}

Wszystkie klasy testowe, jak i zawarte w nich metody testujące jakieś konkretne funkcjonalności naszego kodu, powinny być oznaczone adnotacją @isTest. Oznacza ona, że znajdujący się wewnątrz danej klasy, czy metody kod, odpowiedzialny jest wyłącznie za testowanie. Do sprawdzenia tego, czy dana metoda zwróciła prawidłową wartość wykorzystać możemy jedną z trzech metod klasy System: assert, assertEquals, assertNotEquals.

Testy dla klas wykorzystujących obiekty Salesforce

Nieco inaczej wygląda jednak sytuacja pisania testów jednostkowych dla klas, które wykonują jakąś logikę na obiektach Salesforce. Pierwszą bardzo istotną rzeczą, o której należy pamiętać jest to, że klasy testowe NIE mają dostępu do większości rekordów znajdujących się w bazie danych. Są od tego jednak pewne wyjątki, ponieważ klasy testowe mogą mieć dostęp do rekordów dotyczących niektórych standardowych danych (takich jak np. Users, Profiles, Record Types). Możemy też wykorzystać adnotację @IsTest(SeeAllData=true), która sprawia, że metody klasy testowej (lub pojedyncza klasa testowa – wszystko zależy od tego, czy umieścimy adnotacje przed klasą, czy przed konkretnymi metodami) zyskują dostęp do wszystkich rekordów, również tych niestandardowych. Jeśli w metodzie oznaczonej wspomnianą adnotacją zmienimy w jakiś sposób rekordy używając operacji update, po zakończeniu testu zostanie zrobiony tak zwany rollback i wszystkie wprowadzone przez nas zmiany zostaną cofnięte.

Okej, weźmy pod uwagę poniższy, prosty kontroler, którego zadaniem jest dostarczenie do jakiegoś komponentu stworzonego przy użyciu jednego z frameworków Aura Components lub Lightning Web Components rekordów Case.

public class CaseController {
    @AuraEnabled
    public static List<Case> getCases() {
        return [SELECT Id FROM Case];
    }
    
    @AuraEnabled
    public static List<Case> getHighPriorityCases() {
        return [SELECT Id, Priority FROM Case WHERE Priority = 'High'];
    }
}

Po uruchomieniu poniższej klasy testowej testującej jedną z metod naszego kontrolera okaże się, że test się nie powiódł, ponieważ metoda getCases() nie zwróci żadnego rekordu i lista okaże się być pustą.

@isTest
private class CaseControllerTest {
    @isTest
    static void getCasesTest() {
        List<Case> cases = CaseController.getCases();
        System.assert(!cases.isEmpty(), 'List of Cases should not be empty!');
    }
}

W większości przypadków będziemy więc musieli w naszej klasie testowej umieścić metodę, która poprzedzona będzie adnotacją @testSetup i której zadaniem będzie umieszczenie w bazie danych rekordów, które następnie zostaną wykorzystane przez nasze metody testowe. Warto mieć na uwadze to, że metoda oznaczona adnotacją @testSetup podczas uruchomienia testu wykonuje się tylko raz. Po zakończeniu testu przeprowadzanego przez każdą z metod oznaczoną adnotacją @isTest, stan wszystkich rekordów przywracany jest do tego, w którym znajdowały się w metodzie przygotowującej dane. Każda z metod testowych działa więc zawsze na takich danych, jakie przygotowane zostały w metodzie przygotowującej dane.

@isTest
private class CaseControllerTest {
    @testSetup
    static void setupTestData() {
        Case testCase = new Case();
        insert testCase;
    }
    
    @isTest
    static void getCasesTest() {
        List<Case> cases = CaseController.getCases();
        System.assert(!cases.isEmpty(), 'List of Cases should not be empty!');
    }
    
    @isTest
    static void getHighPriorityCasesTest() {
        List<Case> testCases = [SELECT Id FROM Case LIMIT 1];
        testCases[0].Priority = 'High';
        update testCases;

        List<Case> cases = CaseController.getHighPriorityCases();
        System.assert(!cases.isEmpty(), 'List of Cases should not be empty!');
    }
}

W powyższym przykładzie w metodzie setupTestData() oznaczonej adnotacją @testSetup utworzony i umieszczony został w bazie danych jeden Case, dlatego tym razem wszystkie testy zadziałają poprawnie.

Jako że obok limitów związanych z platformą Salesforce trudno jest przejść obojętnie, trzeba pamiętać o nich również w przypadku klas testowych. Dodajmy na końcu każdej z metod naszej klasy testowej dwie linijki kodu, które wypiszą liczbę zapytań, które zostały skierowane do bazy danych oraz liczbę instrukcji DML.

@isTest
private class CaseControllerTest {
    @testSetup
    static void setupTestData() {
        Case testCase = new Case();
        insert testCase;
        
        System.debug('setupTestData queries: ' + System.Limits.getQueries());
        System.debug('setupTestData DML statements: ' + System.Limits.getDmlStatements());
    }
    
    @isTest
    static void getCasesTest() {
        List<Case> cases = CaseController.getCases();
        System.assert(!cases.isEmpty(), 'List of Cases should not be empty!');
        
        System.debug('getCasesTest queries: ' + System.Limits.getQueries());
        System.debug('getCasesTest DML statements: ' + System.Limits.getDmlStatements());
    }
    
    @isTest
    static void getHighPriorityCasesTest() {
        List<Case> testCases = [SELECT Id FROM Case LIMIT 1];
        testCases[0].Priority = 'High';
        update testCases;

        List<Case> cases = CaseController.getHighPriorityCases();
        System.assert(!cases.isEmpty(), 'List of Cases should not be empty!');
        
        System.debug('getHighPriorityCasesTest queries: ' + System.Limits.getQueries());
        System.debug('getHighPriorityCasesTest DML statements: ' + System.Limits.getDmlStatements());
    }
}

Oto co zobaczymy w logach.

21:50:50:074 USER_DEBUG [8]|DEBUG|setupTestData queries: 0
21:50:50:074 USER_DEBUG [9]|DEBUG|setupTestData DML statements: 1
21:50:50:089 USER_DEBUG [17]|DEBUG|getCasesTest queries: 1
21:50:50:089 USER_DEBUG [18]|DEBUG|getCasesTest DML statements: 1
21:50:50:308 USER_DEBUG [30]|DEBUG|getHighPriorityCasesTest queries: 2
21:50:50:308 USER_DEBUG [31]|DEBUG|getHighPriorityCasesTest DML statements: 2

Nasza klasa ma tylko 3 metody i odpowiedzialna jest za przetestowania 2 wyjątkowo prostych metod kontrolera, a na koniec testu już mamy wykorzystane 2 zapytania ze 100 możliwych i 2 instrukcje DML ze 150 możliwych. W praktyce klasy testowe oraz te, które są przez nie testowane, mogą być niezwykle skomplikowane i w wielu przypadkach zbliżać się do limitów. By uniknąć problematycznych sytuacji, dane tworzone w metodzie testowej oraz faktycznie testowany przez nas kod warto umieszczać pomiędzy metodami startTest i stopTest klasy Test. Kod umieszczony pomiędzy tymi metodami zyskuje „nowy” zestaw limitów. Zmodyfikujmy więc naszą klasę, uruchommy ją i ponownie sprawdźmy logi.

@isTest
private class CaseControllerTest {
    @testSetup
    static void setupTestData() {
        Case testCase = new Case();
        
        Test.startTest();
        insert testCase;
        Test.stopTest();
        
        System.debug('setupTestData queries: ' + System.Limits.getQueries());
        System.debug('setupTestData DML statements: ' + System.Limits.getDmlStatements());
    }
    
    @isTest
    static void getCasesTest() {
        Test.startTest();
        List<Case> cases = CaseController.getCases();
        Test.stopTest();
        
        System.assert(!cases.isEmpty(), 'List of Cases should not be empty!');
        
        System.debug('getCasesTest queries: ' + System.Limits.getQueries());
        System.debug('getCasesTest DML statements: ' + System.Limits.getDmlStatements());
    }
    
    @isTest
    static void getHighPriorityCasesTest() {
        List<Case> testCases = [SELECT Id FROM Case LIMIT 1];
        testCases[0].Priority = 'High';
        update testCases;
        
        Test.startTest();
        List<Case> cases = CaseController.getHighPriorityCases();
        Test.stopTest();
        
        System.assert(!cases.isEmpty(), 'List of Cases should not be empty!');
        
        System.debug('getHighPriorityCasesTest queries: ' + System.Limits.getQueries());
        System.debug('getHighPriorityCasesTest DML statements: ' + System.Limits.getDmlStatements());
    }
}

Logi.

22:06:50:082 USER_DEBUG [11]|DEBUG|setupTestData queries: 0
22:06:50:082 USER_DEBUG [12]|DEBUG|setupTestData DML statements: 0
22:06:50:100 USER_DEBUG [23]|DEBUG|getCasesTest queries: 0
22:06:50:100 USER_DEBUG [24]|DEBUG|getCasesTest DML statements: 0
22:06:50:227 USER_DEBUG [40]|DEBUG|getHighPriorityCasesTest queries: 1
22:06:50:228 USER_DEBUG [41]|DEBUG|getHighPriorityCasesTest DML statements: 1

Teraz tylko w dwóch ostatnich linijkach widzimy jakąkolwiek liczbę większą od 0, ponieważ w przypadku ostatniej metody testowej faktycznie poza zakresem metod Test.startTest() oraz Test.stopTest() wykorzystując jedno zapytanie SOQL na pozyskanie rekordu Case z bazy danych oraz jedną instrukcję DML w celu zaktualizowania rekordu, by metoda testowana faktycznie mogła go znaleźć.

Testy dla triggerów

Triggery pozwalają na wykonanie jakichś operacji związanych z rekordami, w momencie gdy przeprowadzana jest na nich jakaś instrukcja DML: insert, update, delete, undelete. Możemy na przykład stworzyć Trigger before insert, który przypisze do pola Description rekordu Case jakąś domyślną wartość, jeśli będzie ono równe null (w tym momencie dla uproszenia przykładu pomińmy fakt, że na ogół unika się stosowania jakiejkolwiek logiki bezpośrednio w triggerach).

trigger CaseTrigger on Case (before insert) {
    if (Trigger.isInsert && Trigger.isBefore) {
        for (Case c : Trigger.New) {
            if (c.Description == null) {
                c.Description = 'Default Case Description';
            }
        }
    }
}

Jak mogłaby więc wyglądać klasa testowa sprawdzająca, czy opis na pewno został zaktualizowany?

@isTest
private class CaseTriggerTest {
    @isTest
    static void testBeforeInsert() {
        Case c = new Case();
        
        Test.startTest();
        insert c;
        Test.stopTest();
        
        List<Case> cases = [SELECT Description FROM Case LIMIT 1];
        
        System.assert(!cases.isEmpty(), 'List of Cases should not be empty!');
        System.assertEquals('Default Case Description', cases[0].Description, 'Case Description should be: Default Case Description.');
    }
}

W tym przypadku kluczową linijką odpowiedzialną za wykonanie logiki na utworzonym przez nas rekordzie jest insert i dlatego to właśnie on otoczony jest metodami startTest i stopTest i na ogół podobnie wygląda to w przypadku całej reszty instrukcji DML. W przypadku testowania Triggerów należy pamiętać o ponownym pobraniu rekordu z bazy danych, tak jak zostało to zrobione w linijce 11 za pomocą zapytania SOQL. Poniższy przykład więc nie zadziała, ponieważ pole Description będzie równe null.

@isTest
private class CaseTriggerTest {
    @isTest
    static void testBeforeInsert() {
        Case c = new Case();
        
        Test.startTest();
        insert c;
        Test.stopTest();

        System.assertEquals('Default Case Description', c.Description, 'Case Description should be: Default Case Description.');
    }
}

Testy dla Batch Apex

Mianem Batch Apex określa się klasy implementujące interfejs Database.Batchable i tym samym jego 3 metody jakimi są start, execute i finish. Takie klasy pozwalają na przeprowadzanie różnych operacji na ogromnych ilościach rekordów w sposób asynchroniczny. Stwórzmy więc wyjątkowo prosty Batch, którego zadaniem będzie usunięcie wszystkich rekordów Case, których pole Status ma wartość Closed.

public class ClosedCasesCleanerBatch implements Database.Batchable<sObject> {
    public Database.QueryLocator start(Database.BatchableContext context) {
        return Database.getQueryLocator([SELECT Id FROM Case WHERE Status = 'Closed']);
    }
    
    public void execute(Database.BatchableContext context, List<Case> cases) {
        delete cases;
    }
    
    public void finish(Database.BatchableContext context) {

    }
}

Tak będzie wyglądała przykładowa klasa testowa.

@isTest
private class ClosedCasesCleanerBatchTest {
    @testSetup
    static void setupTestData() {
        Case c = new Case();
        c.Status = 'Closed';
        
        Test.startTest();
        insert c;
        Test.stopTest();
    }
    
    @isTest
    static void testBatch() {
        Test.startTest();
        ClosedCasesCleanerBatch closedCasesCleanerBatch = new ClosedCasesCleanerBatch();
        Database.executeBatch(closedCasesCleanerBatch);
        Test.stopTest();
        
        List<Case> cases = [SELECT Id FROM Case LIMIT 1];
        System.assert(cases.isEmpty(), 'List of Cases should be empty!');
    }
}

W przypadku testowania Batch Apex stosowanie metod startTest i stopTest klasy Test ma szczególne znaczenie. Trzeba pamiętać, że Batch Apex wykonuje się asynchronicznie i jeśli nie „otoczymy” kodu związanego z Batchem wspomnianymi metodami, to asercja wskaże, że lista pobranych rekordów Case nie będzie pusta i będzie tak dlatego, że Case nie został jeszcze usunięty. Kod asynchroniczny znajdujący się między metodami startTest i stopTest wykonany zostanie jako kod synchroniczny zaraz po metodzie stopTest. W takiej sytuacji mamy więc pewność, że nasz Batch zakończył wszystkie operacje i rekord Case faktycznie został usunięty.

Testy dla Apex Web Services

Sprawienie, by napisane przez nas za pośrednictwem języka Apex klasy stały się za pośrednictwem REST i najpopularniejszych metod HTTP nie jest niczym trudnym i pomóc w tym mogą niektóre z adnotacji. Stwórzmy w pierwszej kolejności klasę, która da nam dostęp do rekordów Case znajdujących się na naszym orgu i która pozwalała będzie na pobieranie, dodawanie, usuwanie i modyfikowanie rekordów za pośrednictwem metod HTTP takich jak GET, POST, DELETE, PUT i PATCH. Przykład jest jednym z najprostszych. Jest to nieco zmodyfikowana wersja przykładowego serwisu pochodząca ze strony trailhead.salesforce.com – to samo tyczy się jej klasy testowej.

@RestResource(urlMapping='/Cases/*')
global with sharing class CaseManager {
    @HttpGet
    global static Case getCaseById() {
        RestRequest request = RestContext.request;
        String caseId = request.requestURI.substring(request.requestURI.lastIndexOf('/') + 1);
        Case result =  [SELECT CaseNumber,
                        	   Subject,
                               Status,
                               Origin,
                               Priority
                        FROM Case
                        WHERE Id = :caseId];
        return result;
    }
    
    @HttpPost
    global static ID createCase(String subject, String status, String origin, String priority) {
        Case newCase = new Case(Subject = subject, Status = status, Origin = origin, Priority = priority);
        insert newCase;
        return newCase.Id;
    }
    
    @HttpDelete
    global static void deleteCase() {
        RestRequest request = RestContext.request;
        String caseId = request.requestURI.substring(request.requestURI.lastIndexOf('/') + 1);
        Case caseToDelete = [SELECT Id FROM Case WHERE Id = :caseId];
        delete caseToDelete;
    }
    
    @HttpPut
    global static ID upsertCase(String subject, String status,
        String origin, String priority, String id) {
        Case thisCase = new Case(
                Id=id,
                Subject=subject,
                Status=status,
                Origin=origin,
                Priority=priority);

        upsert thisCase;

        return thisCase.Id;
    }
    
    @HttpPatch
    global static ID updateCaseFields() {
        RestRequest request = RestContext.request;
        String caseId = request.requestURI.substring(request.requestURI.lastIndexOf('/') + 1);
        Case thisCase = [SELECT Id FROM Case WHERE Id = :caseId];
        Map<String, Object> params = (Map<String, Object>)JSON.deserializeUntyped(request.requestbody.tostring());
        
        for(String fieldName : params.keySet()) {
            thisCase.put(fieldName, params.get(fieldName));
        }
        
        update thisCase;
        return thisCase.Id;
    }    
}

A tutaj mamy klasę testową.

@isTest
private class CaseManagerTest {
    static final String REQUEST_URI_PATTERN = 'https://playful-otter-dkf1ep-dev-ed.my.salesforce.com/services/apexrest/Cases/{0}';
    
    // HTTP GET.
    @isTest
    static void testGetCaseById() {
        Id recordId = createTestRecord();
        RestRequest request = new RestRequest();
        request.requestUri = String.format(REQUEST_URI_PATTERN, new List<String> { recordId });
        request.httpMethod = 'GET';
        RestContext.request = request;
        
        Test.startTest();
        Case thisCase = CaseManager.getCaseById();
       	Test.stopTest();
        
        System.assertNotEquals(null, thisCase, 'Case should not be null!');
        System.assertEquals('Test record', thisCase.Subject, 'Value of the Subject field should be: Test record!');
    }
    
    // HTTP POST.
    @isTest
    static void testCreateCase() {
        Test.startTest();
        Id thisCaseId = CaseManager.createCase('Ferocious chipmunk', 'New', 'Phone', 'Low');
        Test.stopTest();
        
        System.assertNotEquals(null, thisCaseId, 'Case Id should not be null!');
        Case thisCase = [SELECT Subject FROM Case WHERE Id = :thisCaseId];
        System.assertNotEquals(null, thisCase, 'Case should not be null!');
        System.assertEquals('Ferocious chipmunk', thisCase.Subject, 'Value of the Subject field should be: Ferocious chipmunk!');
    }
    
    // HTTP DELETE.
    @isTest static void testDeleteCase() {
        Id recordId = createTestRecord();
        RestRequest request = new RestRequest();
        request.requestUri = String.format(REQUEST_URI_PATTERN, new List<String> { recordId });
        request.httpMethod = 'DELETE';
        RestContext.request = request;
        
        Test.startTest();
        CaseManager.deleteCase();
        Test.stopTest();
        
        List<Case> cases = [SELECT Id FROM Case WHERE Id=:recordId];
        System.assert(cases.isEmpty(), 'List of Cases should be empty!');
    }
    
    // HTTP PUT.
    @isTest
    static void testUpsertCase() {
		Id recordId = createTestRecord();
        
        Test.startTest();
        Id caseId = CaseManager.upsertCase('Ferocious chipmunk', 'Working', 'Phone', 'Low', recordId);
        Test.stopTest();
        
        Case thisCase = [SELECT Status FROM Case WHERE Id = :caseId];
        System.assert(thisCase != null);
        System.assertEquals('Working', thisCase.Status, 'Value of the Status field should be: Working!');
    }
    
    // HTTP PATCH.
    @isTest
    static void testUpdateCaseFields() {
        Id recordId = createTestRecord();
        RestRequest request = new RestRequest();
        request.requestUri = String.format(REQUEST_URI_PATTERN, new List<String> { recordId });
        request.httpMethod = 'PATCH';
        request.addHeader('Content-Type', 'application/json');
        request.requestBody = Blob.valueOf('{"status": "Working"}');
        RestContext.request = request;
        
        Test.startTest();
        Id thisCaseId = CaseManager.updateCaseFields();
        Test.stopTest();
        
        System.assertNotEquals(null, thisCaseId, 'Case Id should not be null!');
        Case thisCase = [SELECT Status FROM Case WHERE Id = :thisCaseId];
        System.assertNotEquals(null, thisCase, 'Case should not be null!');
        System.assertEquals('Working', thisCase.Status, 'Value of the Status field should be: Working!');
    }
    
    static Id createTestRecord() {
        Case caseTest = new Case(Subject = 'Test record', Status = 'New', Origin = 'Phone', Priority = 'Medium');
        insert caseTest;
        return caseTest.Id;
    }          
}

Jak widać w przypadku większości metod wszystko tak naprawdę sprowadza się do umiejętnego wykorzystania klas RestRequest i RestContext i nadaniu ich polom odpowiednich wartości.

Wskazówka #1

Dane do testów przygotowane mogą zostać mogą wewnątrz test klas testowych na kilka sposobów:

  • utworzenie danych wewnątrz metod testowych
  • oznaczenie klasy lub metody testowej adnotacją @IsTest(SeeAllData=true), tak by miała dostęp do rekordów znajdujących się już w bazie danych
  • załadowanie danych testowych w postaci pliku text/csv w postaci tzw. static resource i załadowanie go wewnątrz klasy testowej za pomocą statycznej metody loadData klasy Test np. Test.loadData(Cases.sObjectType, 'nazwa_pliku_static_resource_z_danymi_testowymi’)

Wskazówka #2

Klasy testowe w Salesforce uruchomić można w zasadzie na kilka różnych sposobów, a poniżej znajdują się niektóre z nich:

  1. uruchomienie wszystkich klas testowych:
    • Developer Console ➡️ zakładka Test ➡️ Run All
    • Setup ➡️ Apex Classes ➡️ przycisk Run All Tests
  2. uruchomienie wybranych klas testowych:
    • Developer Console ➡️ otwieramy interesującą nas klasę testową ➡️ Run Test
    • Developer Console ➡️ zakładka Test ➡️ New Run ➡️ wypieramy interesujące nas metody poszczególnych klas lub całe klasy testowe ➡️ Run
      • w tym przypadku mamy możliwość pominięcia zebrania informacji na temat Code Coverage poprzez wybranie odpowiedniej opcji, co przyspieszyć może wykonanie testów
    • Developer Console ➡️ zakładka Test ➡️ New Suite Run ➡️ wybieramy interesujące nas Test Suite ➡️ Run Suites
    • Setup ➡️ Apex Test Execution ➡️ Select Tests ➡️ zaznaczamy interesujące nas klasy ➡️ Run
      • w tym przypadku mamy możliwość pominięcia zebrania informacji na temat Code Coverage poprzez wybranie odpowiedniej opcji, co przyspieszyć może wykonanie testów
      • z tego miejsca nie mamy wglądu w Code Coverage poszczególnych klas

Wskazówka #3

Jak wspomniałem na początku, kod napisany w języku Apex musi być pokryty w co najmniej 75% klasami testowymi, jeśli chcemy wdrożyć kod na środowisko produkcyjne. Dodatkowo testy muszą działać bez najmniejszego problemu. Jeśli pojawi się jakikolwiek błąd choćby tylko w przypadku jednej asercji, wdrożenie również się nie powiedzie.

Warto jednak dodać, że do Code Coverage nie liczą się komentarze lub kod znajdującyc się wewnątrz klas oznaczonych adnotacją @IsTest. Brane pod uwagę są wyłącznie „wykonywalne” linijki kodu. Obrazuje to poniższy przykład, gdzie do pokrycia są tylko 4 linijki. W jego przypadku na niebiesko podświetlone są pokryte linijki kodu, a na czerwono te, które nie są pokryte (metoda nie testuje bowiem scenariusza, gdzie którykolwiek z przekazanych argumentów ma wartośc null. Komentarze i linijka z deklaracją klasy nie są brane pod uwagę.

Pokrycie łatwo poprawić możemy dodając dodatkową metodę testową.

Code Coverage w przypadku platformy Salesforce rządzi się swoimi prawami, więc warto jest zajrzeć do dokumentacji i poczytać więcej na ten temat. Weźmy pod uwagę kolejny przykład. W tym powyższym do pokrycia były 4 linilki kodu i 4 udało się pokryć. Co jednak, jeśli null check z naszej klasy zapiszemy w jednej linijce? Wtedy okaże się, że do pokrycia są już nie 4, a tylko 3 linijki kodu. Dodatkowa metoda testowa w zasadzie nie jest potrzebna mimo, że metoda jest w zasadzie identyczna. Prawda? 🙂

Okej, to zmodyfikujmy jeszcze naszą prostą metodę, by wykorzystywała ternary operator i wyrzućmy już może komentarze.

No to teraz do pokrycia zostały już tylko 2 linijki i chyba nie muszę już wspominać, że całą tę metodę można zapisać w jednej linijce. 🙂

Czy to więc oznacza, że jeżeli stosujemy w naszym kodzie ternary operator, czy też zapisujemy z jakiegokolwiek powodu bloki warunkowe w jednej linijce lub robimy jakieś jeszcze inne fikołki, to nie musimy sprawdzać wszystkich ścieżek jakimi podążyć może nasz kod, ponieważ liczy się tylko pokrycie? 🙃

Wskazówka #4

Może nie wskazówka, a bardziej swego rodzaju informacja. Salesforce regularnie wypuszcza aktualizacje platformy (tzw. release), stąd też z biegiem czasu pojawiają się nowe funkcjonalności, klasy Apex i tak dalej. I tak właśnie w jednej z ostatnich aktualizacji wprowadzona została klasa Assert, która posiada kilkanaście metod pozwalających na sprawdzenie wyników działania naszego kodu w klasach testowych. Wspominam o tym, ponieważ w momencie gdy pisałem przedstawiony w tym artykule kod, klasa Assert jeszcze nie istniała i do tworzenia asercji wykorzystywałem metody klasy System. I mimo, że metody (związane z asercjami) tej klasy nadal są dostępne, to z pewnością warto rozważyć korzystanie z możliwości jakie daje nowa klasa Assert, choćby ze względu na (przynajmniej według mnie) czytelniejszy zapis. Poniżej znajduje się kilka przykładów:

@isTest
private class MathUtilityTest {
    @isTest
    static void newAssertClassMethods() {
        Integer result;
        
        // Asercja jest spełniona, jeśli wartość jest równa null.
        Assert.isNull(result, 'The expected value should be null.');
        
        result = Math.round(1.6);
        
        // Porównuje dwie wartości i asercja jest spełniona, jeśli są one równe.
        Assert.areEqual(2, result, 'The expected value is 2.');
        
        // Porównuje dwie wartości i asercja jest spełniona, jeśli nie są one równe.
        Assert.areNotEqual(3, result, 'The expected value should be different than 3.');
        
        // Asercja jest spełniona, jeśli podany warunek jest prawdziwy.
        Assert.isTrue(result < 10, 'The expected value should be < 10.');
        
        // Asercja jest spełniona, jeśli podany warunek jest fałszywy.
        Assert.isFalse(result > 10, 'The expected value should NOT be > 10.');
        
        // Asercja jest spełniona, jeśli wartość nie jest równa null.
        Assert.isNotNull(result, 'The expected value should NOT be null.');
        
        Case testCase = new Case();
        
        // Asercja jest spełniona, jeśli dany obiekt jest instancją podanego typu.
        Assert.isInstanceOfType(testCase, Case.class, 'The expected value type should be instance of Case.');
        
        // Asercja jest spełniona, jeśli dany obiekt nie jest instancją podanego typu.
        Assert.isNotInstanceOfType(testCase, Account.class, 'The expected value type should NOT be instance of Account');
        
        // Natychmiast zwraca błąd krytyczny, który powoduje zatrzymanie wykonywania kodu.
        Assert.fail('Fatal Test Error!');
    }
}

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *