Testy jednostkowe w świecie „bez setera”

23

Nie uważam się za eksperta DDD, ale jako architekt rozwiązań staram się stosować najlepsze praktyki, gdy tylko jest to możliwe. Wiem, że wokół DDD jest wiele dyskusji na temat przeciwników i przeciwników „stylu” setera no (publicznego) i widzę obie strony argumentu. Mój problem polega na tym, że pracuję w zespole o dużej różnorodności umiejętności, wiedzy i doświadczenia, co oznacza, że ​​nie mogę ufać, że każdy programista zrobi wszystko „we właściwy sposób”. Na przykład, jeśli nasze obiekty domeny są zaprojektowane w taki sposób, że zmiany stanu wewnętrznego obiektu są przeprowadzane za pomocą metody, ale zapewniają ustawiacze właściwości publicznych, ktoś nieuchronnie ustawi właściwość zamiast wywoływać metodę. Skorzystaj z tego przykładu:

public class MyClass
{
    public Boolean IsPublished
    {
        get { return PublishDate != null; }
    }

    public DateTime? PublishDate { get; set; }

    public void Publish()
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        PublishDate = DateTime.Today;

        Raise(new PublishedEvent());
    }
}

Moim rozwiązaniem było uczynienie ustawień właściwości prywatnymi, co jest możliwe, ponieważ ORM, którego używamy do uwodnienia obiektów, wykorzystuje odbicie, aby mógł uzyskać dostęp do ustawień prywatnych. Stanowi to jednak problem podczas próby napisania testów jednostkowych. Na przykład, gdy chcę napisać test jednostkowy, który weryfikuje wymóg, że nie możemy ponownie opublikować, muszę wskazać, że obiekt został już opublikowany. Z pewnością mogę to zrobić przez dwukrotne wywołanie funkcji Publikuj, ale mój test zakłada, że ​​Publikacja jest poprawnie zaimplementowana dla pierwszego wywołania. To wydaje się trochę śmierdzące.

Uczyńmy scenariusz nieco bardziej realistycznym za pomocą następującego kodu:

public class Document
{
    public Document(String title)
    {
        if (String.IsNullOrWhiteSpace(title))
            throw new ArgumentException("title");

        Title = title;
    }

    public String ApprovedBy { get; private set; }
    public DateTime? ApprovedOn { get; private set; }
    public Boolean IsApproved { get; private set; }
    public Boolean IsPublished { get; private set; }
    public String PublishedBy { get; private set; }
    public DateTime? PublishedOn { get; private set; }
    public String Title { get; private set; }

    public void Approve(String by)
    {
        if (IsApproved)
            throw new InvalidOperationException("Already approved.");

        ApprovedBy = by;
        ApprovedOn = DateTime.Today;
        IsApproved = true;

        Raise(new ApprovedEvent(Title));
    }

    public void Publish(String by)
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        if (!IsApproved)
            throw new InvalidOperationException("Cannot publish until approved.");

        PublishedBy = by;
        PublishedOn = DateTime.Today;
        IsPublished = true;

        Raise(new PublishedEvent(Title));
    }
}

Chcę napisać testy jednostkowe, które weryfikują:

  • Nie mogę opublikować, dopóki dokument nie zostanie zatwierdzony
  • Nie mogę ponownie opublikować dokumentu
  • Po opublikowaniu wartości Opublikowany przez i Opublikowane przez są odpowiednio ustawione
  • Po opublikowaniu publikowane jest EventEvent

Bez dostępu do ustawiaczy nie mogę wprowadzić obiektu w stan wymagany do przeprowadzenia testów. Otwarcie dostępu do seterów uniemożliwia dostęp.

Jak rozwiązałeś (d) ten problem?

SonOfPirate
źródło
Im więcej o tym myślę, tym bardziej myślę, że cały twój problem ma metody z efektami ubocznymi. A raczej zmienny niezmienny obiekt. Czy w świecie DDD nie należy zwracać nowego obiektu Dokument zarówno z Zatwierdź, jak i Publikuj, zamiast aktualizować wewnętrzny stan tego obiektu?
pdr
1
Szybkie pytanie, z którego O / RM korzystasz. Jestem wielkim fanem EF, ale deklarowanie seterów jako chronionych traktuje mnie trochę źle.
Michael Brown,
Mamy teraz mieszankę ze względu na rozwój w wolnym wybiegu, do którego zostałem oskarżony o wtargnięcie. Niektóre ADO.NET używające AutoMapper do nawadniania z DataReadera, kilku modeli Linq-SQL-a (które będą następne do zastąpienia ) i niektóre nowe modele EF.
SonOfPirate
Dwukrotne wywołanie Publish wcale nie śmierdzi i jest na to sposobem.
Piotr Perak

Odpowiedzi:

27

Nie mogę wprowadzić obiektu w stan wymagany do przeprowadzenia testów.

Jeśli nie możesz wprowadzić obiektu do stanu potrzebnego do przeprowadzenia testu, to nie możesz wprowadzić obiektu do stanu w kodzie produkcyjnym, więc nie ma potrzeby testowania tego stanu. Oczywiście, nie jest to prawdą w przypadku, można umieścić obiekt do stanu potrzebne, wystarczy zadzwonić Zatwierdź.

  • Nie mogę opublikować, dopóki dokument nie zostanie zatwierdzony: napisz test, który wywołując polecenie opublikuj przed wywołaniem polecenia zatwierdzenia powoduje prawidłowy błąd bez zmiany stanu obiektu.

    void testPublishBeforeApprove() {
        doc = new Document("Doc");
        AssertRaises(doc.publish, ..., NotApprovedException);
    }
    
  • Nie mogę ponownie opublikować dokumentu: napisz test, który zatwierdza obiekt, a następnie wywołanie polecenia opublikuj po pomyślnym zakończeniu, ale drugi raz spowoduje poprawny błąd bez zmiany stanu obiektu.

    void testRePublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        AssertRaises(doc.publish, ..., RepublishException);
    }
    
  • Po opublikowaniu wartości OpublikowanyBy i OpublikowanyOn są poprawnie ustawione: napisz test, który wywołuje zatwierdzenie, a następnie wywołaj publikowanie, stwierdzając, że stan obiektu zmienia się poprawnie

    void testPublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        Assert(doc.PublishedBy, ...);
        ...
    }
    
  • Po opublikowaniu publikowane jest EventEvent: podłącz system zdarzeń i ustaw flagę, aby upewnić się, że jest wywoływana

Musisz także napisać test do zatwierdzenia.

Innymi słowy, nie testuj relacji między polami wewnętrznymi a IsPublished i IsApproved, twój test byłby dość kruchy, jeśli to zrobisz, ponieważ zmiana pola oznaczałaby zmianę kodu testu, więc test byłby zupełnie bezcelowy. Zamiast tego powinieneś przetestować związek między wywołaniami metod publicznych, w ten sposób, nawet jeśli zmodyfikujesz pola, nie będziesz musiał modyfikować testu.

Lie Ryan
źródło
Po zatwierdzeniu psuje się kilka testów. Nie testujesz już jednostki kodu, testujesz pełną implementację.
pdr
Podzielam troskę pdr i dlatego wahałem się, czy pójść w tym kierunku. Tak, wydaje się najczystszy, ale nie lubię wielu powodów, dla których indywidualny test może się nie powieść.
SonOfPirate
4
Nie widziałem jeszcze testu jednostkowego, który mógłby zakończyć się niepowodzeniem tylko z jednego możliwego powodu. Możesz także umieścić części testu „manipulowanie stanem” w setup()metodzie - nie w samym teście.
Peter K.
12
Dlaczego zależność od approve()czegoś jest krucha, a jednak zależność od setApproved(true)czegoś nie? approve()jest uzasadnioną zależnością w testach, ponieważ jest zależnością od wymagań. Gdyby zależność istniała tylko w testach, byłby to inny problem.
Karl Bielefeldt
2
@pdr, jak przetestowałbyś klasę stosów? Czy spróbowałbyś przetestować push()i pop()metody niezależnie?
Winston Ewert
2

Jeszcze innym podejściem jest stworzenie konstruktora klasy, który umożliwia ustawienie właściwości wewnętrznych przy tworzeniu wystąpienia:

 public Document(
  String approvedBy,
  DateTime? approvedOn,
  Boolean isApproved,
  Boolean isPublished,
  String publishedBy,
  DateTime? publishedOn,
  String title)
{
  ApprovedBy = approvedBy;
  ApprovedOn = approvedOn;
  IsApproved = isApproved;
  IsApproved = isApproved;
  PublishedBy = publishedBy;
  PublishedOn = publishedOn;
}
Peter K.
źródło
2
To wcale nie jest dobrze skalowane. Mój obiekt może mieć o wiele więcej właściwości, przy czym dowolna liczba z nich może mieć lub nie mieć wartości w dowolnym punkcie cyklu życia obiektu. Kieruję się zasadą, że konstruktory zawierają parametry dla właściwości, które są wymagane, aby obiekt znajdował się w prawidłowym stanie początkowym lub zależności, które musi spełniać obiekt. Celem właściwości w tym przykładzie jest uchwycenie bieżącego stanu podczas manipulowania obiektem. Posiadanie konstruktora z każdą właściwością lub przeciążeniem różnymi kombinacjami to ogromny zapach i, jak powiedziałem, nie skaluje się.
SonOfPirate
Zrozumiany. W twoim przykładzie nie wspomniano o wielu innych właściwościach, a liczba w tym przykładzie jest „na granicy” uznania tego za prawidłowe podejście. Wygląda na to, że mówi ci to coś o twoim projekcie: nie możesz wprowadzić swojego obiektu do żadnego prawidłowego stanu przy tworzeniu wystąpienia. Oznacza to, że musisz ustawić go w prawidłowym stanie początkowym, a oni zmanipulują go do odpowiedniego stanu do testu. To sugeruje, że odpowiedź Lie Ryana jest właściwą drogą .
Peter K.
Nawet jeśli obiekt ma jedną właściwość i nigdy się nie zmieni, to rozwiązanie jest złe. Co powstrzymuje kogokolwiek od używania tego konstruktora w produkcji? Jak oznaczysz tego konstruktora [TestOnly]?
Piotr Perak
Dlaczego jest źle w produkcji? (Naprawdę, chciałbym wiedzieć). Czasami konieczne jest odtworzenie dokładnego stanu obiektu podczas tworzenia ... nie tylko jednego poprawnego obiektu początkowego.
Peter K.
1
Tak więc, chociaż pomaga to wprowadzić obiekt w prawidłowy stan początkowy, testowanie zachowania obiektu w trakcie jego cyklu życia wymaga zmiany obiektu z jego stanu początkowego. Mój OP ma do czynienia z testowaniem tych dodatkowych stanów, gdy nie można po prostu ustawić właściwości w celu zmiany stanu obiektu.
SonOfPirate
1

Jedna strategia polega na tym, że dziedziczysz klasę (w tym przypadku Dokument) i piszesz testy względem odziedziczonej klasy. Dziedziczona klasa pozwala w pewien sposób ustawić stan obiektu w testach.

W C # jedna strategia może polegać na wprowadzeniu wewnętrznych ustawień, a następnie wystawieniu wewnętrznych elementów do przetestowania projektu.

Możesz także użyć klasowego interfejsu API, jak opisano („Z pewnością mogę to zrobić, wywołując dwukrotnie Publish”). To byłoby ustawienie stanu obiektu za pomocą publicznych usług obiektu, nie wydaje mi się to zbyt śmierdzące. W przypadku twojego przykładu prawdopodobnie tak właśnie zrobiłbym.

simoraman
źródło
Pomyślałem o tym jako o możliwym rozwiązaniu, ale wahałem się, czy moje właściwości mogą zostać zastąpione lub narażone setery jako chronione, ponieważ czułem, jakbym otwierał obiekt i przerywał enkapsulację. Myślę, że ochrona chronionych właściwości jest z pewnością lepsza niż publiczna, a nawet wewnętrzna / przyjacielska. Na pewno bardziej zastanowię się nad tym podejściem. To proste i skuteczne. Czasami jest to najlepsze podejście. Jeśli ktoś się nie zgadza, dodaj komentarze ze szczegółami.
SonOfPirate
1

Aby przetestować w absolutnej izolacji polecenia i zapytania otrzymywane przez obiekty domeny, jestem przyzwyczajony do dostarczania każdemu testowi serializacji obiektu w oczekiwanym stanie. W sekcji aranżacji testu ładuje obiekt do przetestowania z pliku, który wcześniej przygotowałem. Na początku zacząłem od serializacji binarnych, ale json okazał się o wiele łatwiejszy w utrzymaniu. Okazało się, że działa dobrze, ilekroć absolutna izolacja w testach zapewnia rzeczywistą wartość.

edytuj tylko notatkę, czasami serializacja JSON kończy się niepowodzeniem (jak w przypadku grafów cyklicznych obiektów, które są zapachem, btw). W takich sytuacjach ratuję się do serializacji binarnej. To trochę pragmatyczne, ale działa. :-)

Giacomo Tesio
źródło
A jak przygotować obiekt w oczekiwanym stanie, jeśli nie ma setera i nie chcesz nazywać go publicznymi metodami, aby go ustawić?
Piotr Perak
Napisałem do tego małe narzędzie. Ładuje klasę poprzez odbicie, która tworzy nowy byt za pomocą swojego publicznego konstruktora (zwykle biorąc tylko identyfikator) i wywołuje na nim tablicę Action <TEntity>, zapisując migawkę po każdej operacji (z konwencjonalną nazwą opartą na indeksie akcji i nazwa jednostki). Narzędzie jest wykonywane ręcznie przy każdym refaktoryzacji kodu encji, a migawki są śledzone przez DCVS. Oczywiście każda akcja wywołuje publiczne polecenie encji, ale odbywa się to na podstawie testów, że w ten sposób są to naprawdę testy jednostkowe .
Giacomo Tesio
Nie rozumiem, jak to cokolwiek zmienia. Jeśli nadal wywołuje metody publiczne w sut (testowany system), to nie różni się od wywoływania tych metod w teście.
Piotr Perak
Po utworzeniu migawek są one przechowywane w plikach. Każdy test nie zależy od sekwencji operacji wymaganych do uzyskania stanu początkowego encji, ale od samego stanu (ładowanego z migawki). Sama metoda jest następnie izolowana od zmian innych metod.
Giacomo Tesio
Co się stanie, gdy ktoś zmieni metodę publiczną zastosowaną do przygotowania stanu szeregowego do testów, ale zapomni o uruchomieniu narzędzia do ponownego wygenerowania obiektu szeregowego? Testy są nadal zielone, nawet jeśli występuje błąd w kodzie. Nadal mówię, że to nic nie zmienia. Nadal korzystasz z metod publicznych, więc skonfiguruj testowane obiekty. Ale uruchamiasz je na długo przed uruchomieniem testów.
Piotr Perak
-7

Mówisz

w miarę możliwości staraj się stosować najlepsze praktyki

i

ORM, którego używamy do uwodnienia obiektów, wykorzystuje odbicie, dzięki czemu ma dostęp do prywatnych ustawień

i muszę pomyśleć, że użycie refleksji do ominięcia kontroli dostępu na twoich zajęciach nie jest tym, co nazwałbym „najlepszą praktyką”. Będzie też strasznie powolne.


Osobiście zeskrobałbym twoją platformę testów jednostkowych i poszedłbym z czymś w klasie - wygląda na to, że piszesz testy z punktu widzenia testowania całej klasy, co jest dobre. W przeszłości, w przypadku niektórych trudnych komponentów, które wymagały testowania, wbudowałem aserts i kod instalacyjny w samą klasę (kiedyś był to powszechny wzorzec projektowy mający metodę test () w każdej klasie), więc tworzysz klienta to po prostu tworzy instancję obiektu i wywołuje metodę testową, która może ustawić się tak, jak chcesz, bez nieprzyjemności, takich jak hack odbicia.

Jeśli martwisz się rozdęciem kodu, po prostu zawiń metody testowe w #ifdefs, aby były dostępne tylko w kodzie debugowania (prawdopodobnie sama najlepsza praktyka)

gbjbaanb
źródło
4
-1: Złomowanie środowiska testowego i powrót do metod testowych w klasie oznaczałoby powrót do ciemnych czasów testowania jednostkowego.
Robert Johnson
9
Nr -1 ode mnie, ale włączenie kodu testowego do produkcji to na ogół Bad Thing (TM) .
Peter K.
co jeszcze robi PO? Trzymać się pieprzenia z prywatnymi seterami ?! To jak wybór trucizny, którą chcesz wypić. Moją sugestią dla OP było umieszczenie testu jednostkowego w kodzie debugowania, a nie w produkcji. Z mojego doświadczenia, umieszczenie testów jednostkowych w innym projekcie oznacza, że ​​projekt i tak jest ściśle powiązany z oryginałem, więc od Dev PoV nie ma większego rozróżnienia.
gbjbaanb