Embedded software vs testy jednostkowe, mockowanie i wstrzykiwanie zależności
January 20, 2024

Jako programista systemów wbudowanych, często stawałem przed wyzwaniem zintegrowania skutecznych testów jednostkowych w środowisku, gdzie zależności sprzętowe są nieodłączną częścią procesu deweloperskiego. W tym artykule chcę podzielić się moimi doświadczeniami i technikami, które pomogły mi przezwyciężyć te wyzwania. Skupię się na wykorzystaniu mocków i wstrzykiwaniu zależności, demonstrując, jak te metody mogą znacząco ułatwić proces testowania jednostkowego w systemach wbudowanych. Ten artykuł jest zbiorem moich przemyśleń i praktycznych porad, które mam nadzieję, będą pomocne dla innych programistów w tej dynamicznie rozwijającej się dziedzinie.

Kiedy zaczynałem karierę jako programista wbudowanych systemów, często słyszałem, że testowanie jednostkowe jest fantastyczne, ale nie w świecie systemów wbudowanych. Było mi trudno zaakceptować to stwierdzenie. Byłem zdeterminowany, aby zakwestionować status quo. Zastanawiałem się. dlaczego my, w świecie systemów wbudowanych, nie możemy przyjąć wszystkich najlepszych praktyk stosowanych na całym świecie?
Więc wziąłem pierwszy lepszy framework do testowania jednostkowego i napisałem mój inauguracyjny przypadek testowy. Co za ekscytująca przygoda! Uruchomiłem go i...

$ ./run_tests Running unit tests... Segmentation fault (core dumped) Backtrace: #0 0x00007f9c88931780 in i2c_read_byte () from /path/to/libi2c.c #1 0x00007f9c887d5a15 in sensor_read_data () from /path/to/libsensor.c #2 0x00007f9c887d63f2 in main () from /path/to/test_executable Aborted (core dumped)

Okazało się, że nie przemyślałem zależności sprzętowych, które istniały w projekcie. Jak to pokonać? I o tym jest ten artykuł.

Czym jest mock?

Mock to symulowany obiekt, który naśladuje zachowanie rzeczywistego obiektu w kontrolowany sposób. Jest używany w testowaniu oprogramowania do zastępowania zależności i symulowania ich zachowania podczas testów jednostkowych. Mocki są często używane, gdy rzeczywiste zależności są trudne do ustawienia lub interakcji podczas testowania.
Pozwalają programistom izolować testowany kod i skupić się na konkretnych scenariuszach bez polegania na faktycznej implementacji zależności.
Mocki można tworzyć ręcznie lub za pomocą frameworków do mockowania, takich jak GMock.

Dependency injection

Mówimy o wstrzykiwaniu zależności, gdy chcemy wskazać, że moduł może łatwo wymienić część kodu, z którego korzysta.
Ale dlaczego i kiedy chcielibyśmy to zrobić?
Jednym z przykładów jest sytuacja, gdy potrzebujemy zmienić zachowanie klasy, która jest używana w innej wersji sprzętowej niż wcześniej. Innym powodem może być użycie mocka zamiast produkcyjnego modułu do testów jednostkowych.

Spójrzmy na poniższy prosty przykład:

Ponieważ moduł "Testable code" wykorzystuje "Production module", umożliwia to łatwe wstrzykiwanie, co skutkuje czystszym i bardziej łatwym do utrzymania kodem. Poprzez stworzenie osobnego pliku wykonywalnego z różnymi zależnościami wstrzykniętymi do "Kodu testowalnego", możemy skutecznie używać go do celów testowych.
Podejście to poprawia nasz przykład, jak pokazano poniżej:

Runtime dependency injection

Ale zastanówmy się, jak możemy zaimplementować wstrzykiwanie zależności w sposób luźny, dostosowany do codziennych potrzeb biznesowych.
Przedstawię Ci dwie metody.
Pierwsza metoda polega na stosowaniu wstrzykiwania zależności w czasie wykonywania programu. Można to osiągnąć, przekazując obiekt oparty na polimorfizmie przez referencję podczas tworzenia naszego "Testable code". Poniżej znajdziesz przykład, który może być użyty w kodzie produkcyjnym:

// foo.h #pragma once class Dependency { public: Dependency() {} ~Dependency() {} virtual void DoSomething() {} virtual int GetSomething() {} }; class Foo { public: Foo(Dependency* const dependency) : dependency_(dependency) {} ~Foo() {} void bar() { dependency_->DoSomething(); } int get() { return dependency_->GetSomething(); } private: Dependency * const dependency_; };

Compile-time dependency injection

W przypadku wstrzykiwania zależności w czasie kompilacji zadaniem konsolidatora (linkera) jest połączenie odpowiedniej implementacji.
Ważne jest, aby znać system budowania, z którego korzystasz, ale warto spróbować tego podejścia. W ten sposób nie musisz modyfikować swojego API, aby umożliwić wstrzykiwanie modułu.
Można to osiągnąć podczas kompilacji (a dokładniej, podczas konsolidacji).
Oto bardzo prosty przykład, który rozwinę w następnej sekcji:

// foo.h #pragma once class Dependency { public: Dependency(); ~Dependency(); void DoSomething(); int GetSomething(); }; class Foo { public: Foo() {} ~Foo() {} void bar() { dependency_.DoSomething(); } int get() { return dependency_.GetSomething(); } private: Dependency dependency_ {}; }; // dependency.cpp // Implementation in separate file! #include "foo.h" Dependency::Dependency() {} Dependency::~Dependency() {} void Dependency::DoSomething() {} int Dependency::GetSomething() {}

Dzięki oddzieleniu deklaracji funkcji od definicji w klasie Dependency, mamy możliwość skompilowania oddzielnego pliku z alternatywną definicją tych funkcji. W następnej sekcji wyjaśnię, jak działa ten proces.

Mockowanie przy pomcy compile-time dependency injection

Teraz, korzystając z GMock, pokażę Ci, jak wstrzykiwać mocki zamiast naszej "produkcyjnej" zależności.
Proszę, spójrz:

// dependency_mock.h #pragma once #include "gmock/gmock.h" class DependencyMock { public: MOCK_METHOD(void, DoSomething, (), ()); MOCK_METHOD(int, GetSomething, (), ()); MOCK_METHOD(void, _constructor, (), ()); MOCK_METHOD(void, _destructor, (), ()); // singleton static DependencyMock & get_instance() { static DependencyMock instance; return instance; } private: DependencyMock() = default; }; // dependency_mock.cpp #include "dependency_mock.h" #include "dependency.h" // This is the place where mocks are injected Dependency::Dependency() { DependencyMock::get_instance()._constructor(); } Dependency::~Dependency() { DependencyMock::get_instance()._destructor(); } void Dependency::DoSomething() { DependencyMock::get_instance().DoSomething(); } int Dependency::GetSomething() { return DependencyMock::get_instance().GetSomething(); } // test.cpp #include "foo.h" #include "dependency_mock.h" #include #include using namespace ::testing; class Fixture : public Test { protected: // To verify singleton mock object. Usually it happens in destructor void TearDown() override { ASSERT_TRUE(Mock::VerifyAndClearExpectations(&DependencyMock::get_instance())); } }; TEST_F(Fixture, WhenCreatingFooThenDependencyIsCreated) { EXPECT_CALL(DependencyMock::get_instance(), _constructor).Times(1); auto foo = std::make_shared(); EXPECT_TRUE(foo != nullptr); }

Przyjrzyjmy się bliżej dostarczonemu kodowi. Zaimplementowaliśmy wzorzec singleton w klasie DependencyMock, aby uniknąć jawnej instancji klasy mock. Zamiast tego, ustalamy nasze oczekiwania co do tego, które funkcje powinny być wywoływane i z jakimi parametrami. To podejście wymaga od nas jawnego wyczyszczenia i zweryfikowania oczekiwania mocka.
W typowym scenariuszu byłoby to zrobione w destruktorze mocka, ale ponieważ używamy wzorca singleton, musimy sobie z tym poradzić w fazie końcowej (tear down) naszego zestawu testowego, tak jak to:

void TearDown() override { ASSERT_TRUE(Mock::VerifyAndClearExpectations(&DependencyMock::get_instance())); }

Jak możemy ustalić oczekiwania względem konstruktora? W przypadku testowym "WhenCreatingFooThenDependencyIsCreated" jest oczywiste, że spodziewamy się również utworzenia obiektu "Dependency".
Przyjrzyjmy się całemu procesowi, aby uzyskać bardziej kompleksowe zrozumienie, jak to działa.
Warto zauważyć, że używając makra "MOCK_METHOD", generujemy funkcję członkowską, na której możemy ustawić nasze oczekiwania, takie jak:

EXPECT_CALL(DependencyMock::get_instance(), _constructor).Times(1);

Dobrze... ale jak możemy być pewni, że nasza klasa Dependency została utworzona? Właśnie omówiliśmy klasę DependencyMock. To tutaj dzieje się magia. Dzięki wstrzykiwaniu zależności w czasie kompilacji mamy możliwość zdefiniowania naszej klasy Dependency w dowolny sposób, jaki chcemy.
A w celu mockowania, możemy również przekierować wywołanie do naszego obiektu mock, jak pokazano w pliku dependency_mock.cpp:

Dependency::Dependency() { DependencyMock::get_instance()._constructor(); }

Dzięki tej linii, za każdym razem, gdy tworzymy obiekt Dependency podczas testów jednostkowych, faktycznie wywołujemy DependencyMock.
To jest powód, dla którego wszystko może funkcjonować bez negatywnego wpływu na API.

Podsumowanie

Testowanie jednostkowe w świecie systemów wbudowanych może być wyzwaniem ze względu na zależności sprzętowe. Jednak istnieją techniki, takie jak używanie mocków i wstrzykiwanie zależności, które mogą pomóc pokonać te wyzwania. Mocki pozwalają programistom na symulowanie rzeczywistych obiektów podczas testowania, podczas gdy wstrzykiwanie zależności umożliwia łatwą wymianę części kodu. Frameworki takie jak GMock mogą być używane do mockowania z wstrzykiwaniem zależności w czasie kompilacji.

Referencje

GoogleTest User’s Guide

#GoodByte #EmbeddedSoftware #DependencyInjectionInEmbedded