Jakie zasady projektowania promują testowalny kod? (projektowanie testowalnego kodu vs testowanie projektu poprzez testy)

54

Większość projektów, nad którymi pracuję, rozważa rozwój i testy jednostkowe w oderwaniu, co sprawia, że ​​pisanie testów jednostkowych w późniejszym przypadku jest koszmarem. Moim celem jest pamiętanie o testach podczas samych faz projektowania wysokiego i niskiego poziomu.

Chcę wiedzieć, czy istnieją dobrze zdefiniowane zasady projektowania, które promują testowalny kod. Jedną z takich zasad, którą ostatnio zrozumiałem, jest Inwersja zależności poprzez wstrzyknięcie zależności i Inwersja kontroli.

Czytałem, że istnieje coś takiego jak SOLID. Chcę zrozumieć, czy przestrzeganie zasad SOLID pośrednio prowadzi do kodu, który można łatwo przetestować? Jeśli nie, to czy istnieją dobrze zdefiniowane zasady projektowania, które promują testowalny kod?

Wiem, że istnieje coś takiego jak Test Driven Development. Chociaż bardziej interesuje mnie projektowanie kodu z myślą o testowaniu podczas samej fazy projektowania, niż kierowanie projektem przez testy. Mam nadzieję, że to ma sens.

Jeszcze jedno pytanie związane z tym tematem brzmi: czy w porządku jest ponowne uwzględnienie istniejącego produktu / projektu i wprowadzenie zmian w kodzie i projekcie w celu umożliwienia napisania jednostkowego przypadku testowego dla każdego modułu?

CKing
źródło
Dziękuję Ci. Dopiero zacząłem czytać artykuł i to już ma sens.
1
To jedno z moich pytań do rozmowy kwalifikacyjnej („Jak zaprojektować kod, który będzie łatwo testowany jednostkowo?”). Pojedynczo pokazuje mi, czy rozumie testowanie jednostkowe, kpiny / kasowanie, OOD i potencjalnie TDD. Niestety, odpowiedzi zwykle brzmią: „Utwórz testową bazę danych”.
Chris Pitman,

Odpowiedzi:

56

Tak, SOLID to bardzo dobry sposób na zaprojektowanie kodu, który można łatwo przetestować. Jako krótki podkład:

S - Zasada pojedynczej odpowiedzialności: Obiekt powinien zrobić dokładnie jedną rzecz i powinien być jedynym obiektem w bazie kodu, który wykonuje tę jedną rzecz. Weźmy na przykład klasę domeny, powiedzmy fakturę. Klasa Faktura powinna reprezentować strukturę danych i reguły biznesowe faktury stosowanej w systemie. Powinna to być jedyna klasa reprezentująca fakturę w bazie kodu. Można to dalej rozbić, aby powiedzieć, że metoda powinna mieć jeden cel i powinna być jedyną metodą w bazie danych, która spełnia tę potrzebę.

Postępując zgodnie z tą zasadą, zwiększasz testowalność swojego projektu, zmniejszając liczbę testów, które musisz napisać, które testują tę samą funkcjonalność na różnych obiektach, a także zazwyczaj uzyskujesz mniejsze części funkcjonalności, które są łatwiejsze do przetestowania w izolacji.

O - Zasada otwarta / zamknięta: klasa powinna być otwarta na rozszerzenie, ale zamknięta na zmianę . Gdy obiekt istnieje i działa poprawnie, idealnie nie powinno być potrzeby powrotu do tego obiektu, aby wprowadzić zmiany, które dodają nową funkcjonalność. Zamiast tego obiekt należy rozszerzyć, albo poprzez jego wyprowadzenie, albo przez podłączenie do niego nowych lub różnych implementacji zależności, aby zapewnić tę nową funkcjonalność. Pozwala to uniknąć regresji; możesz wprowadzić nową funkcjonalność tam, gdzie jest potrzebna, bez zmiany zachowania obiektu, ponieważ jest on już używany gdzie indziej.

Przestrzegając tej zasady, ogólnie zwiększasz zdolność kodu do tolerowania „próbnych”, a także unikasz przepisywania testów w celu przewidywania nowego zachowania; wszystkie istniejące testy dla obiektu powinny nadal działać w przypadku nierozszerzonej implementacji, a także powinny działać nowe testy dla nowej funkcjonalności korzystającej z rozszerzonej implementacji.

L - Zasada podstawienia Liskowa: Klasa A, zależna od klasy B, powinna mieć możliwość używania dowolnego X: B bez znajomości różnicy. Zasadniczo oznacza to, że wszystko, czego użyjesz jako zależność, powinno mieć podobne zachowanie, jakie widzi klasa zależna. Jako krótki przykład powiedzmy, że masz interfejs IWriter, który udostępnia zapis (ciąg), który jest implementowany przez ConsoleWriter. Teraz musisz napisać do pliku, więc utworzysz FileWriter. Czyniąc to, musisz upewnić się, że FileWriter może być używany w taki sam sposób, jak ConsoleWriter (co oznacza, że ​​jedyny sposób, w jaki osoba zależna może z nim współdziałać, wywołując Write (string)), a także dodatkowe informacje, które może wymagać FileWriter zadanie (takie jak ścieżka i plik do zapisu) musi być dostarczone skądinąd poza zależną.

Jest to ogromna zaleta przy pisaniu testowalnego kodu, ponieważ projekt zgodny z LSP może w dowolnym momencie zastąpić rzeczywisty obiekt „kpionym” obiektem bez zmiany oczekiwanego zachowania, umożliwiając testowanie małych fragmentów kodu w sposób pewny że system będzie wtedy pracował z podłączonymi prawdziwymi obiektami.

I - Zasada segregacji interfejsu: Interfejs powinien mieć tak mało metod, jak to możliwe, aby zapewnić funkcjonalność roli zdefiniowanej przez interfejs . Krótko mówiąc, więcej mniejszych interfejsów jest lepszych niż mniejszych interfejsów. Wynika to z faktu, że duży interfejs ma więcej powodów do zmiany i powoduje więcej zmian w innym miejscu w bazie kodu, które mogą nie być konieczne.

Zgodność z ISP poprawia testowalność poprzez zmniejszenie złożoności testowanych systemów i zależności tych SUT. Jeśli testowany obiekt zależy od interfejsu IDoThreeThings, który udostępnia DoOne (), DoTwo () i DoThree (), musisz wyśmiewać obiekt, który implementuje wszystkie trzy metody, nawet jeśli obiekt używa tylko metody DoTwo. Ale jeśli obiekt zależy tylko od IDoTwo (który udostępnia tylko DoTwo), możesz łatwiej wyśmiewać obiekt, który ma tę jedną metodę.

D - Zasada odwrócenia zależności: konkrecje i abstrakcje nigdy nie powinny zależeć od innych konkrecji, ale od abstrakcji . Ta zasada bezpośrednio egzekwuje zasadę luźnego sprzężenia. Obiekt nigdy nie powinien wiedzieć, czym JEST obiekt; zamiast tego powinno obchodzić się, co robi obiekt. Dlatego użycie interfejsów i / lub abstrakcyjnych klas bazowych zawsze powinno być preferowane w porównaniu z zastosowaniem konkretnych implementacji podczas definiowania właściwości i parametrów obiektu lub metody. Pozwala to na zamianę jednej implementacji na inną bez konieczności zmiany użycia (jeśli podążasz za LSP, co idzie w parze z DIP).

Ponownie, ma to ogromne znaczenie dla testowalności, ponieważ pozwala raz jeszcze wstrzyknąć próbną implementację zależności zamiast implementacji „produkcyjnej” do testowanego obiektu, jednocześnie testując obiekt w dokładnie takiej formie, jaką będzie miał podczas w produkcji. Jest to klucz do testów jednostkowych „w izolacji”.

KeithS
źródło
16

Czytałem, że istnieje coś takiego jak SOLID. Chcę zrozumieć, czy przestrzeganie zasad SOLID pośrednio prowadzi do kodu, który można łatwo przetestować?

Jeśli zastosowane poprawnie, tak. Jest post na blogu Jeffa wyjaśniający zasady SOLID w naprawdę krótkim czasie (wspomniany podcast też jest wart wysłuchania), proponuję zajrzeć tam, jeśli dłuższe opisy cię wyrzucają.

Z mojego doświadczenia, 2 zasady SOLID odgrywają główną rolę w projektowaniu testowalnego kodu:

  • Zasada segregacji interfejsów - powinieneś preferować wiele interfejsów specyficznych dla klienta zamiast mniejszej liczby interfejsów ogólnego przeznaczenia. Jest to zgodne z zasadą pojedynczej odpowiedzialności i pomaga w projektowaniu klas zorientowanych na funkcje / zadania, które w zamian są znacznie łatwiejsze do przetestowania (w porównaniu do bardziej ogólnych lub często nadużywanych „menedżerów” i „kontekstów” ) - mniej zależności , mniej skomplikowane, bardziej szczegółowe, oczywiste testy. Krótko mówiąc, małe elementy prowadzą do prostych testów.
  • Zasada inwersji zależności - projekt na podstawie umowy, a nie wdrożenia. Przyniesie to największe korzyści podczas testowania złożonych obiektów i uświadamiania sobie, że nie potrzebujesz całego wykresu zależności, aby go skonfigurować , ale możesz po prostu kpić z interfejsu i zrobić to.

Wierzę, że te dwa najbardziej pomogą ci przy projektowaniu pod kątem testowalności. Pozostałe mają również wpływ, ale powiedziałbym, że nie są tak duże.

(...) czy w porządku jest ponowne uwzględnienie istniejącego produktu / projektu i wprowadzenie zmian w kodzie i projekcie w celu umożliwienia napisania jednostkowego przypadku testowego dla każdego modułu?

Bez istniejących testów jednostkowych jest to po prostu proste - proszenie o problemy. Testów jednostkowych jest gwarancją, że kod działa . Wprowadzenie przełomowej zmiany jest zauważane natychmiast, jeśli masz odpowiedni zasięg testów.

Teraz, jeśli chcesz zmienić istniejący kod w celu dodania testów jednostkowych , wprowadza to lukę, w której nie masz jeszcze testów, ale zmieniłeś już kod . Oczywiście możesz nie mieć pojęcia, co złamały twoje zmiany. Jest to sytuacja, której chcesz uniknąć.

Testy jednostkowe i tak warto pisać, nawet w przypadku kodu trudnego do przetestowania. Jeśli kod działa , ale nie jest testowany jednostkowo, właściwym rozwiązaniem byłoby napisanie testów, a następnie wprowadzenie zmian. Należy jednak pamiętać, że zmiana przetestowanego kodu w celu ułatwienia jego testowania jest czymś, na czym kierownictwo może nie chcieć wydawać pieniędzy (prawdopodobnie usłyszysz, że nie przynosi żadnej wartości biznesowej).

km
źródło
wysoka kohezja i niskie sprzęgło
jk.
8

TWOJE PIERWSZE PYTANIE:

SOLID jest rzeczywiście właściwą drogą. Uważam, że dwa najważniejsze aspekty akronimu SOLID, jeśli chodzi o testowalność, to S (Single Responsibility) i D (Dependency Injection).

Jedna odpowiedzialność : Twoje zajęcia powinny naprawdę robić tylko jedną rzecz i tylko jedną rzecz. klasa, która tworzy plik, analizuje dane wejściowe i zapisuje je w pliku, robi już trzy rzeczy. Jeśli twoja klasa robi tylko jedną rzecz, wiesz dokładnie, czego się po niej spodziewać, a projektowanie przypadków testowych powinno być dość łatwe.

Dependency Injection (DI): Daje ci kontrolę nad środowiskiem testowym. Zamiast tworzyć obce obiekty w kodzie, wstrzykujesz go za pomocą konstruktora klasy lub wywołania metody. Kiedy nie poddajesz się dyskusji, po prostu zamieniasz prawdziwe klasy na kody pośredniczące lub kpiny, które całkowicie kontrolujesz.

TWOJE DRUGIE PYTANIE: Idealnie, piszesz testy dokumentujące działanie twojego kodu przed jego refaktoryzacją. W ten sposób możesz udokumentować, że refaktoryzacja odtwarza te same wyniki, co oryginalny kod. Problemem jest jednak to, że działający kod jest trudny do przetestowania. To klasyczna sytuacja! Moja rada to: Przed testowaniem jednostkowym dokładnie przemyśl fakturę. Jeśli możesz; napisz testy dla działającego kodu, następnie refaktoryzuj kod, a następnie refaktoryzuj testy. Wiem, że będzie to kosztować godziny, ale będziesz bardziej pewny, że refaktoryzowany kod działa tak samo jak stary. Powiedziawszy to, poddałem się wiele razy. Klasy mogą być tak brzydkie i nieporządne, że przepisywanie jest jedynym sposobem, aby umożliwić ich testowanie.

Morten
źródło
4

Oprócz innych odpowiedzi, które koncentrują się na uzyskaniu luźnego sprzężenia, chciałbym powiedzieć słowo o testowaniu skomplikowanej logiki.

Kiedyś musiałem przetestować jednostkę, której logika była skomplikowana, z wieloma warunkami warunkowymi i gdzie trudno było zrozumieć rolę pól.

Zamieniłem ten kod na wiele małych klas reprezentujących maszynę stanu . Logika stała się znacznie prostsza, ponieważ różne stany poprzedniej klasy stały się wyraźne. Każda klasa stanowa była niezależna od innych, dlatego można je było łatwo przetestować.

Fakt, że stany były jawne, ułatwił wyliczenie wszystkich możliwych ścieżek kodu (przejścia stanów), a tym samym napisanie testu jednostkowego dla każdego z nich.

Oczywiście nie każdą złożoną logikę można modelować jako maszynę stanu.

Barjak
źródło
3

SOLID to doskonały początek, z mojego doświadczenia wynika, że ​​cztery aspekty SOLID naprawdę dobrze działają z testami jednostkowymi.

  • Zasada pojedynczej odpowiedzialności - każda klasa robi jedną rzecz i tylko jedną rzecz. Obliczanie wartości, otwieranie pliku, parsowanie łańcucha, cokolwiek. Ilość nakładów i wyników, a także punkty decyzyjne powinny być zatem bardzo minimalne. Co ułatwia pisanie testów.
  • Zasada podstawienia Liskova - powinieneś być w stanie zastępować kody pośredniczące i próbne bez zmiany pożądanych właściwości (oczekiwanych wyników) kodu.
  • Zasada segregacji interfejsów - oddzielenie punktów kontaktowych przez interfejsy sprawia, że ​​bardzo łatwo jest użyć kpiny, takiej jak Moq, do tworzenia kodów pośredniczących i próbnych. Zamiast polegać na konkretnych klasach, po prostu polegasz na czymś, co implementuje interfejs.
  • Zasada wstrzykiwania zależności - umożliwia wstrzykiwanie tych kodów pośredniczących i próbnych do kodu za pomocą konstruktora, właściwości lub parametru w metodzie, którą chcesz przetestować.

Chciałbym również przyjrzeć się różnym wzorom, zwłaszcza wzorcom fabrycznym. Załóżmy, że masz konkretną klasę, która implementuje interfejs. Utworzyłbyś fabrykę, aby utworzyć instancję konkretnej klasy, ale zamiast tego zwraca interfejs.

public interface ISomeInterface
{
    int GetValue();
}  

public class SomeClass : ISomeInterface
{
    public int GetValue()
    {
         return 1;
    }
}

public interface ISomeOtherInterface
{
    bool IsSuccess();
}

public class SomeOtherClass : ISomeOtherInterface
{
     private ISomeInterface m_SomeInterface;

     public SomeOtherClass(ISomeInterface someInterface)
     {
          m_SomeInterface = someInterface;
     }

     public bool IsSuccess()
     {
          return m_SomeInterface.GetValue() == 1;
     }
}

public class SomeFactory
{
     public virtual ISomeInterface GetSomeInterface()
     {
          return new SomeClass();
     }

     public virtual ISomeOtherInterface GetSomeOtherInterface()
     {
          ISomeInterface someInterface = GetSomeInterface();

          return new SomeOtherClass(someInterface);
     }
}

W swoich testach możesz użyć Moq lub innej frakcji próbnej, aby zastąpić tę wirtualną metodę i zwrócić interfejs swojego projektu. Ale jeśli chodzi o kod wykonawczy, fabryka się nie zmieniła. W ten sposób możesz również ukryć wiele szczegółów implementacji, kod implementacyjny nie dba o to, jak zbudowany jest interfejs, liczy się tylko odzyskanie interfejsu.

Jeśli chcesz nieco rozwinąć tę kwestię, zdecydowanie polecam przeczytanie The Art of Unit Testing . Daje kilka świetnych przykładów korzystania z tych zasad i jest dość szybki.

bwalk2895
źródło
1
Nazywa się to zasadą „inwersji” zależności, a nie zasadą „iniekcji”.
Mathias Lykkegaard Lorenzen