Tydzień z Adą - wszystkie wpisy

Ada została wymyślona jako język, w który swoją składnią utrudnia popełnianie błędów. Dlatego część przypadków sprawdzanych zwykle przez unit testy w Adzie w ogóle się nie skompiluje. Jednak mimo wszystko unit testy pozostają ważnym elementem sprawdzania poprawności działania systemu. Co więcej normy dotyczące systemów safety-critical często nakazują wykorzystanie unit testów i osiągnięcie odpowiedniego pokrycia kodu. Z tego artykułu dowiecie się jak wygląda wsparcie dla unit testów w Adzie.

Artykuł powstał w ramach “Tygodnia z Adą” podczas którego od poniedziałku do piątku będą ukazywać się materiały na temat języka Ada. Będzie między innymi dlaczego Ada tak dobrze nadaje się do safety-critical, pokażę swoje pierwsze próby z pisaniem programów w Adzie, a także postaram się uruchomić Adę na STM32.

Biblioteka AUnit

Biblioteka do unit testów w Adzie nosi nazwę AUnit i jest instalowana razem z toolchainem GNAT w wersji native. Jej źródła możemy znaleźć w folderze GNAT/2018/include/aunit, do tego jest jeszcze dokumentacja w GNAT/2018/share/doc/aunit i przykłady w GNAT/2018/share/examples/aunit. Dokumentację AUnit możemy znaleźć również na stronie AdaCore. Jej lektura zdradza nam, że biblioteka jest dosyć złożona i trudna w użyciu.

Pisane przez nas testy noszą w AUnit nazwę Test Routine. Test Routine’y możemy zgrupować w Test Case’y (to są odpowiedniki grup testów z innych frameworków), aby dla każdej Test Routine móc wywołać funkcje Setup i Teardown przed i po teście. Mamy również funkcje Set_Up_Case i Tear_Down_Case odpowiedzialne za konfigurację i sprzątanie przed/po całej grupie testów. Istnieje również coś takiego jak Test Fixture. Z dokumentacji różnica między Test Fixture a Test Case nie jest dla mnie do końca jasna, może to się za chwilę rozjaśni, kiedy dojdziemy do analizy przykładów. Test Case są łączone w Test Suite. Test Suite są z kolei przekazywane do Runnera, który uruchamia testy i generuje raporty. Wszystko to razem tworzy dosyć długi łańcuch powiązań.

Prosty przykład

To tyle jeżeli chodzi o teorię, teraz pora uruchomić jakieś przykłady. Problem w tym, że one nie działają! Próbowałem uruchomić przykład simple_test z folderu examples. Jednak build kończył się błędem – nie umiał sobie utworzyć folderów obj i lib. Stworzyłem je więc ręcznie, niestety niewiele to pomogło, bo kompilacja wywaliła się chwilę później na nierozpoznanej fladze -gnat05. Wersja community kompilatora GNAT obsługuję tylko Adę 2012, więc musiałem ręcznie zmodyfikować pliki .gpr z konfiguracją projektu i podmienić wystąpienia -gnat05 na -gnat12. Po tych zabiegach przykład się zbudował i uruchomił:

C:\Projekty\Ada\simple_test\test_math.exe

OK Test Math package

Total Tests Run:   1
Successful Tests:  1
Failed Assertions: 0
Unexpected Errors: 0
[2019-04-17 21:07:48] process terminated successfully, elapsed time: 01.63s

Przyjrzyjmy się teraz co zawiera projekt simple_test:

Mamy główny projekt Harness zawierający folder tests z plikami implementującymi unit testy. Mamy implementacje Test Case (math-test), Test Suite (math_suite) i runnera (test_math). Przyjrzyjmy się zatem implementacji. Oto zawartość math-test.ads:

with AUnit;
with AUnit.Simple_Test_Cases;

package Math.Test is

   type Test is new AUnit.Simple_Test_Cases.Test_Case with null record;

   function Name (T : Test) return AUnit.Message_String;

   procedure Run_Test (T : in out Test);

end Math.Test;

i math-test.adb:

with AUnit.Assertions; use AUnit.Assertions;

package body Math.Test is

   function Name (T : Test) return AUnit.Message_String is
      pragma Unreferenced (T);
   begin
      return AUnit.Format ("Test Math package");
   end Name;

   procedure Run_Test (T : in out Test) is
      pragma Unreferenced (T);
      I1 : constant Int := 5;
      I2 : constant Int := 3;
   begin
      Assert (I1 + I2 = 8, "Incorrect result after addition");
      Assert (I1 - I2 = 2, "Incorrect result after subtraction");
   end Run_Test;

end Math.Test;

W pliku .ads mamy zadeklarowaną klasę Test dziedziczącą po AUnit.Simple_Test_Case.Test_Case i dwie metody – Name i Run_Test. Niestety składnia klas w Adzie jest bardzo dziwna. W pliku .adb widzimy implementację. Metoda Name zwraca nam nazwę testu, a Run_Test to scenariusz testowy zawierający asserty.

Przyjrzyjmy się teraz test_suite.ads:

with AUnit.Test_Suites; use AUnit.Test_Suites;

package Math_Suite is

   function Suite return Access_Test_Suite;

end Math_Suite;

i math_suite.adb:

with Math.Test;

package body Math_Suite is

function Suite return Access_Test_Suite is
Ret : constant Access_Test_Suite := new Test_Suite;
begin
Ret.Add_Test (new Math.Test.Test);
return Ret;
end Suite;

end Math_Suite;

Tym razem w pliku .ads zadeklarowana jest funkcja Suite zwracająca referencję Access_Test_Suite. W Adzie referencje są nazywane właśnie access. W pliku .adb widzimy zmienną Ret właśnie typu Access_Test_Suite, której jest przypisywany obiekt tworzony za pomocą operatora new. Mamy tu więc wykorzystanie dynamicznej alokacji pamięci.

Plik test_math.adb z implementacją runnera to:

with AUnit.Reporter.Text;
with AUnit.Run;
with Math_Suite; use Math_Suite;

procedure Test_Math is
   procedure Runner is new AUnit.Run.Test_Runner (Suite);
   Reporter : AUnit.Reporter.Text.Text_Reporter;
begin
   Runner (Reporter);
end Test_Math;

Jest tu tworzony Runner, jako argument dostaje wartość zwracaną przez funkcję Suite (ten zapis funkcji bezargumentowej bez pustych nawiasów jest nieintuicyjny dla osób przyzwyczajonych do składni C). Następnie Runner jest uruchamiany z odpowiednim Reporterem odpowiadającym za utworzenie raportu z wynikami. Text_Reporter po prostu drukuje wyniki na konsolę. Mamy jeszcze do wyboru XML Reporter.

Przykład z Test_Fixture

Drugim przykładem jaki przeanalizuję jest test_fixture, który również znajdziemy w folderze aunit/examples. Podobnie jak w poprzednim przypadku, musimy stworzyć foldery obj i lib oraz zmienić pliki .gpr, aby projekt się skompilował. W poprzednim przykładzie dwa asserty były sprawdzane w jednym teście. Teraz są rozbite na dwa testy.

Plik math-test.ads wygląda teraz tak:

with AUnit;
with AUnit.Test_Fixtures;

package Math.Test is

   type Test is new AUnit.Test_Fixtures.Test_Fixture with record
      I1 : Int;
      I2 : Int;
   end record;

   procedure Set_Up (T : in out Test);

   procedure Test_Addition (T : in out Test);
   procedure Test_Subtraction (T : in out Test);

end Math.Test;

a math-test.adb tak:

with AUnit.Assertions; use AUnit.Assertions;

package body Math.Test is

   procedure Set_Up (T : in out Test) is
   begin
      T.I1 := 5;
      T.I2 := 3;
   end Set_Up;

   procedure Test_Addition (T : in out Test) is
   begin
      Assert (T.I1 + T.I2 = 8, "Incorrect result after addition");
   end Test_Addition;

   procedure Test_Subtraction (T : in out Test) is
   begin
      Assert (T.I1 - T.I2 = 2, "Incorrect result after subtraction");
   end Test_Subtraction;

end Math.Test;

Teraz klasa Test dziedziczy po Test_Fixture i zawiera dwa dodatkowe pola I1 i I2. Są też dwie procedury testowe – Test_Addition i Test_Subtraction, a także procedura Set_Up. W pliku .adb widzimy implementację. Set_Up wywołuje się przed każdym testem i ustawia pola I1 i I2. Z kolei każda z metod testowych sprawdza po jednym asercie z poprzedniego przykładu.

Plik math_suite.ads pozostał taki sam jak poprzednio. Różni się jednak math_suite.adb:

with Math.Test;         use Math.Test;
with AUnit.Test_Caller;

package body Math_Suite is

   package Caller is new AUnit.Test_Caller (Math.Test.Test);

   function Suite return Access_Test_Suite is
      Ret : constant Access_Test_Suite := new Test_Suite;
   begin
      Ret.Add_Test
        (Caller.Create ("Test addition", Test_Addition'Access));
      Ret.Add_Test
        (Caller.Create ("Test subtraction", Test_Subtraction'Access));
      return Ret;
   end Suite;

end Math_Suite;

Teraz w funkcji Suite wykorzystujemy Test_Caller, do którego dodajemy metody testowe.

Po tych dwóch przykładach widzimy, że unit testy w Adzie z użyciem AUnit wymagają od nas nieco konfiguracji. Musimy stworzyć oddzielny projekt testowy, zaimplementować Test_Case, Test_Suite i Runnera. Jest więc z tym trochę roboty. Przydałoby się więc to zautomatyzować.

Narzędzie GNATtest

GNATtest służy do generowania środowiska testowego dla istniejącego projektu. Jest narzędziem konsolowym, można go też wywoływać z poziomu GPSa. Posiada również możliwość generowania stubów. Niestety na jego temat jest dosyć mało informacji w internecie. Najlepszym źródłem wiedzy jest chyba tutorial z AdaCore i ta strona.

Podobnie jak w przypadku AUnit, razem z toolchainem otrzymaliśmy przykłady GNATtesta. Można je znaleźć w folderze GNAT/2018/share/examples/gnattest. Przykłady zawierają pliki podstawowego projektu, do którego następnie generujemy testy przy użyciu GNATtest. Do każdego przykładu dołączony jest readme ze szczegółowym opisem. Ja jednak nie korzystałem z GNATtesta w konsoli, tylko wygenerowałem testy z GPSa. Tym razem przynajmniej się skompilowały i uruchomiły bez wymaganej interwencji. Oto struktura wygenerowanego projektu:

Jak widać jest ona dużo bardziej skomplikowana niż w przypadku samego AUnit.

GNATtest ma wiele wad. Przede wszystkim generuje domyślnie tylko po jednym teście na każdą procedurę. To oczywiście za mało. Jest opcja, żeby dodać więcej testów opisana na StackOverflow. Trzeba w deklaracji funkcji dodać informację o każdym teście:

   function Sqrt (X : Float) return Float
   with Test_Case => (Name => "test case 1",
                      Mode => Nominal,
                      Requires => X < 16.0,
                      Ensures  => Sqrt'Result < 4.0),
        Pre => (X >= 0.0);

Jak dla mnie to niepotrzebne zaśmiecanie kodu produkcyjnego informacjami o testach.

Kolejną wadą, jak to zwykle bywa w automatycznych generatorach, jest duża ilość śmieciowego kodu. GNATtest generuje między innymi procedury i zmienne z nazwami zawierającymi hashe. Poza tym dodając implementację testu edytujemy ten wygenerowany plik. GNATtest wnętrza testów nie rusza, ale jak byśmy chcieli dodać jakieś funkcje pomocnicze, czy zadeklarować jakieś typy danych – możemy je stracić przy następnym uruchomieniu automatu.

Dlatego raczej odradzałbym użycie GNATtest. Zamiast tego można stworzyć template’y zawierające szkielet Test_Case, Test_Suite i Runnera. Niestety IDE nam tu nie pomoże. Nie znalazłem w GPSie opcji definiowania template’ów.

Unit testy w większym projekcie

Aby wykorzystać unit testy w większym projekcie najlepiej po prostu poświęcić więcej czasu na dokładne zrozumienie działania AUnit i napisać własne testy bez posiłkowania się żadnym generatorem. Punktem, który trzeba dokładniej przemyśleć jest rozłożenie folderów i podprojektów. We wszystkich przykładach dostarczanych przez AUnit i GNATtest tworzony jest oddzielny projekt testowy, gdzie kod produkcyjny jest podprojektem. Oczywiście testy jednych funkcji produkcyjnych wymagają mockowania innych, więc takich projektów będziemy potrzebowali całkiem sporo. Dlatego pewnie będzie potrzebne coś na kształt makefile. Chyba, że jednak uda się to rozwiązać sensownie za pomocą podprojektów.

Podsumowanie

Jestem trochę zawiedziony wsparciem Ady dla unit testów. Przedstawione rozwiązania nie wyglądają na nadające się do zastosowania w dużym projekcie. Nie trafiłem również na opis zaawansowanej konfiguracji nigdzie w necie. Po pierwszych próbach wydaje mi się, że da się to dobrze przemyśleć i skonfigurować ale na pewno wymaga to nieco czasu i trochę prób i błędów. Tutaj doskwiera małe community Ady. W popularniejszych językach takie rzeczy po prostu można gdzieś wyczytać, albo podejrzeć na GitHubie.

Kolejnym problemem jest brak biblioteki do mocków dla Ady. Mamy tylko proste stuby generowane przez GNATtest. Ciekawy jestem, czy składnia Ady umożliwia zrobienie jakiejś sensownej biblioteki do mocków. W końcu w C i C++ w tym celu wykorzystujemy macra, w Javie są refleksje.

Tydzień z Adą - Nawigacja