Wzorce testów jednostkowych#

  • Wzorce do tworzenia asercji

  • Wzorce do budowania fikstur

  • Wzorce do klas przypadków testowych

Four-Phase Test#

Każdy test składa się z czterech wykonywanych sekwencyjnie faz.

  1. Setup. W fazie pierwszej ustawiana jest fikstura testu. Ta faza jest wymagana do tego, aby testowany system - SUT (System Under Test) wykazał się oczekiwanym zachowaniem. W fazie inicjalizacyjnej konfigurowane są obiekty pozorujące (mock).

  2. Exercise. W fazie drugiej dokonujemy interakcji z testowanym obiektem (SUT).

  3. Verify. W fazie trzeciej dokonujemy weryfikacji oczekiwanego stanu lub zachowania testowanego systemu.

  4. Teardown. W fazie czwartej przywracamy testowane środowisko do stanu z przed testu.

State Verification#

Po wykonaniu testu (fazie Exercise) dokonywana jest inspekcja stanu testowanego systemu (SUT). Otrzymane wartości porównywane są z wartościami oczekiwanymi dla poprawnego działania systemu.

Najczęściej weryfikowany stan przechowywany jest w obiekcie SUT, ale zdarza się również, że wymagane jest sprawdzenie stanu innego obiektu biorącego udział w teście.

State Verification powinien być używany, gdy interesuje nas tylko stan końcowy systemu, a nie w jaki sposób system znalazł się w takim stanie.

TEST(ListTest, SizeOfListReflectsItemsAddedToIt)
{
    List<string> list;
    list.add("Nazwisko");

    ASSERT_EQ(1, list.size());
}

Prezentowany test przejdzie z implementacją size(), która zawsze zwraca 1. W takim przypadku test jest wystarczająco prosty, aby odczytać intencję jego autora.

Guard Assertion#

Czasami jednak warto zweryfikować nie tylko stan przed, ale również stan po teście, a więc użyć wzorca Guard Assertion. Wzorzec Guard Assertion polega na ujawnieniu założeń zrobionych dla fikstury przed wywołaniem funkcjonalności, którą chcemy testować.

TEST(ListTest, ListIsNoLongerEmptyAfterAddingAnItemToIt)
{
    List<String> list;
    ASSERT_TRUE(list.is_empty()); // guard assertion
    list.add("Tekst");
    ASSERT_FALSE(list.is_empty()); // weryfikacja stanu
}

Guard Assertion gwarantuje, że metoda is_empty() zwróci poprawnie wartość true dla pustej listy zanim wywołamy metodę add(), którą testujemy.

Wzorzec Guard Assertion jest często używamy z wzorcem Resulting State Assertion. Te dwa wzorce są łączone w sekwencję, w której test najpierw weryfikuje, czy stan przed odpowiada oczekiwaniom autora testu, a potem przechodzi do wywołania funkcjonalności i zweryfikowania stanu wynikowego.

Zdarza się jednak, że celem dodania Guard Assertion jest upewnienie się, czy założenie dotyczące stanu początkowego fikstury jest prawidłowe. W takich przypadkach sensownie jest przesunąć Guard Assertion(s) na koniec metody inicjalizującej fiksturę.

Delta Assertion#

Jeśli w testach istnieje fikstura, której stanu nie możemy zakodować na sztywno, to nie należy weryfikować stanu absolutnego po wywołaniu kodu w teście. Lepiej sprawdzić, czy różnica (delta) pomiędzy stanem początkowym a końcowym jest zgodna z naszymi oczekiwaniami.

TEST(ListTest, SizeOfListReflectsItemsAddedToIt)
{
    List<string> list = create_list(); // create_list() dostarcza wypełniony obiekt listy
    int size_before = list.size(); // zapamiętanie stanu początkowego

    list.add("Tekst");

    ASSERT_EQ(size_before + 1, list.size()); // weryfikacja przyrostowa
}

Przewaga wzorca Delta Assertions nad wzorcem Resulting State Assertions polega na tym, że test skupia się na tym, co jest testowane, zamiast zwracać pozornie dowolne wartości.

Custom Assertion#

Zdarza się, że długość kod weryfikującego nasze oczekiwania przekracza długość kodu wymaganą do wywołania kodu w teście. W takim przypadku zaleca się wyodrębnienie metody Custom Assertion z testu w celu hermetyzacji złożonej logiki weryfikacji w prostej metodzie, którą możemy wywołać z testu.

Custom Assertion jest stosowane ze względu na możliwość wykonywania różnych typów rozmytego dopasowywania. Na przykład, jeśli chcemy porównać dwa obiekty jedynie w oparciu o zestaw wybranych właściwości.

Innym zastosowaniem jest sytuacja, w której obiekty nie implementują operatora porównania w odpowiedni sposób.

Używanie Custom Assertion pozwala również zdefiniować bardziej znaczące komunikaty błędu, które zostaną wyświetlone w razie niepowodzenia testu.

TEST(MeetingCalendarTest, GetsNextAppointmentDate)
{
    MeetingCalendar calendar;
    // pominięto: ustaw spotkanie w kalendarzu

    Date time = calendar.get_next_appointment();

    AssertDate(time);
}

void AssertDate(Date time)
{
    // jeśli obiekt czasu nie jest prawidłowy
    FAIL() << "Invalid time";
}

Behavior Verification#

Wzorzec Behavior Verification nie weryfikuje poprawności zwracanych wartości, lecz sprawdza, czy nasz kod współdziała z obiektami współpracownikami w oczekiwany przez nas sposób. Każdy test weryfikuje jakie metody, i w jaki sposób, są wywoływane na współpracownikach przez testowany obiekt (SUT).

Typowym zastosowaniem wzorca Behavior Verification jest rozwijanie aplikacji w stylu “outside-in”. W takim przypadku weryfikujemy wywołania na obiektach, które nie mają jeszcze implementacji (obiektach Mock).

Important

W jednym teście możemy używać zarówno stylu asercji opartego na stanie, jak i stylu opartego na interakcji.

Przykład:

class FlightRepository
{
public:
    virtual ~FlightRepository() = default;
    virtual void add(const Flight& flight) = 0;
};

class MockFlightRepository : public FlightRepository
{
public:
    MOCK_METHOD1(add, void (const Flight&));
};

class FlightServiceTests : public ::testing::Test
{
protected:
    MockFlightRepository flight_repository_;
    FlightReservationService sut_;

public:
    FlightServiceTests() : sut_{flight_repository_}
    {}
};

TEST_F(FlightServiceTests, CanAddReservationToRepository)
{
    auto reservation_request = Mother::create_reservation_request();

    EXPECT_CALL(flight_repository_, add(reservation_request.flight)).Times(1);

    sut_.make_reservation(reservation_request);
}

Object Mother#

Object Mother jest specjalizowaną fabryką, której rolą jest dostarczenie istotnych dla testu danych. Dane te są przez fabrykę odpowiednio skonfigurowane.

struct Mother
{
    constexpr static const char* flight_no = "LOT101";
    constexpr static const char* client = "John Newman";
    constexpr static const char* timestamp = "2017/01/01 1:45am";

    static ReservationRequest create_reservation_request()
    {
        return ReservationRequest{Flight{flight_no, 100.0}, client, timestamp};
    }
};

Test jednostkowy wykorzystuje obiekt OrderObjectMother do przejrzystego utworzenia obiektu potrzebnego w logice testu.

TEST_F(FlightServiceTests, CanAddReservationToRepository)
{
    auto reservation_request = Mother::create_reservation_request();

    EXPECT_CALL(flight_repository_, add(reservation_request.flight)).Times(1);

    sut_.make_reservation(reservation_request);
}

Builder Object#

Wzorzec Object Mother nie najlepiej sprawdza się w sytuacji, kiedy musimy uwzględnić wiele wariacji danych testowych. Lepszym rozwiązaniem jest dynamiczne budowanie takich danych na żądanie z wykorzystaniem wzorca Test Builder. Obiekty Test Builder są implementacją klasycznego wzorca Budowniczy (GOF), który umożliwia zbudowanie złożonego obiektu poprzez kolejne wywołania metod budowniczego i odebranie finalnego obiektu przez metodę get().

class ReservationRequestBuilder
{
    constexpr static const char* flight_no = "LOT101";
    constexpr static const char* client = "John Newman";
    constexpr static const char* timestamp = "2017/01/01 1:45am";

    ReservationRequest reservation_request_{Flight{flight_no, 100.0}, client, timestamp};

public:
    ReservationRequestBuilder() = default;

    ReservationRequestBuilder& with_client(const string& client)
    {
        reservation_request_.client = client;

        return *this;
    }

    ReservationRequestBuilder& with_timestamp(const string& timestamp)
    {
        reservation_request_.timestamp = timestamp;

        return *this;
    }

    ReservationRequestBuilder& with_flight(const Flight& flight)
    {
        reservation_request_.flight = flight;

        return *this;
    }

    ReservationRequest get_reservation_request() const
    {
        return reservation_request_;
    }
};

Test korzystający z budowniczego wygląda następująco:

TEST_F(FlightServiceTests, ThrowsWhenTimestampInInvalidFormat)
{
    ReservationRequestBuilder reservation_request_builder;
    reservation_request_builder.with_timestamp("2017|01|01 1:45am");
    auto reservation_request = reservation_request_builder.get_reservation_request();

    EXPECT_THROW(sut_.make_reservation(reservation_request), std::invalid_argument);
}