TDD i pełny zakres testów tam, gdzie potrzebne są wykładnicze przypadki testowe

18

Pracuję nad komparatorem list, aby pomóc w sortowaniu nieuporządkowanej listy wyników wyszukiwania według bardzo konkretnych wymagań od naszego klienta. Wymagania wymagają algorytmu rankingu zgodnego z następującymi regułami w kolejności ważności:

  1. Dokładne dopasowanie do nazwy
  2. Wszystkie słowa wyszukiwanego hasła w nazwie lub synonim wyniku
  3. Niektóre słowa zapytania wyszukiwania w nazwie lub synonimie wyniku (% malejąco)
  4. Wszystkie słowa zapytania wyszukiwania w opisie
  5. Niektóre słowa zapytania wyszukiwania w opisie (% malejąco)
  6. Data ostatniej modyfikacji malejąco

Naturalnym wyborem projektu dla tego komparatora wydawał się ranking punktowy oparty na potęgach 2. Suma mniej ważnych reguł nigdy nie może być więcej niż pozytywnym dopasowaniem reguły o wyższym znaczeniu. Osiąga się to poprzez następujący wynik:

  1. 32
  2. 16
  3. 8 (Drugi wynik w rozstrzygnięciu remisu na podstawie% malejąco)
  4. 4
  5. 2 (Drugi wynik w rozstrzygnięciu remisu na podstawie% malejąco)
  6. 1

W duchu TDD postanowiłem zacząć od testów jednostkowych. Posiadanie przypadku testowego dla każdego unikalnego scenariusza wymagałoby co najmniej 63 unikalnych przypadków testowych bez uwzględnienia dodatkowych przypadków testowych dla logiki dodatkowego przerywacza remisu w regułach 3 i 5. Wydaje się to przesadne.

Rzeczywiste testy będą w rzeczywistości mniej. W oparciu o same reguły, niektóre reguły zapewniają, że niższe reguły będą zawsze prawdziwe (np. Gdy „Wszystkie słowa z wyszukiwanego hasła pojawią się w opisie”, to reguła „Niektóre słowa z wyszukiwanego hasła pojawią się w opisie” zawsze będzie prawdziwa). Czy nadal warto wkładać wysiłek w napisanie każdego z tych przypadków testowych? Czy jest to poziom testowania, który jest zwykle wymagany przy mówieniu o 100% pokryciu testami w TDD? Jeśli nie, to jaka byłaby możliwa do zaakceptowania alternatywna strategia testowania?

wałek klonowy
źródło
1
W tym i podobnych scenariuszach opracowałem „TMatrixTestCase” i moduł wyliczający, dla których można raz napisać kod testowy i wprowadzić do niego dwie lub więcej tablic zawierających dane wejściowe i oczekiwany wynik.
Marjan Venema

Odpowiedzi:

17

Twoje pytanie sugeruje, że TDD ma coś wspólnego z „pisaniem najpierw wszystkich przypadków testowych”. IMHO nie jest to „w duchu TDD”, a wręcz przeciwnie . Pamiętaj, że TDD oznacza „test napędzany rozwoju”, więc trzeba tylko te przypadki testowe, które naprawdę „drive” implementacja, nie więcej. I dopóki twoja implementacja nie jest zaprojektowana w taki sposób, że liczba bloków kodu rośnie wykładniczo z każdym nowym wymaganiem, nie będziesz również potrzebował wykładniczej liczby przypadków testowych. W twoim przykładzie cykl TDD prawdopodobnie będzie wyglądał następująco:

  • zacznij od pierwszego wymagania z listy: słowa z „Dokładne dopasowanie w nazwie” muszą uzyskać wyższy wynik niż wszystko inne
  • teraz piszesz pierwszy przypadek testowy dla tego (na przykład: słowo pasujące do danego zapytania) i implementujesz minimalną ilość działającego kodu, który sprawia, że ​​test przechodzi pozytywnie
  • dodaj drugi przypadek testowy dla pierwszego wymagania (na przykład: słowo nie pasujące do zapytania), a przed dodaniem nowego przypadku testowego zmieniaj istniejący kod, aż drugi test przejdzie pomyślnie
  • w zależności od szczegółów implementacji możesz dodać więcej przypadków testowych, na przykład puste zapytanie, puste słowo itp. (pamiętaj: TDD to podejście oparte na białej skrzynce , możesz skorzystać z faktu, że znasz swoją implementację, kiedy zaprojektuj swoje przypadki testowe).

Następnie zacznij od drugiego wymagania:

  • „Wszystkie słowa wyszukiwanego hasła w nazwie lub synonim wyniku” muszą uzyskać niższy wynik niż „Dokładne dopasowanie w nazwie”, ale wyższy wynik niż wszystko inne.
  • teraz buduj przypadki testowe dla tego nowego wymagania, tak jak powyżej, jeden po drugim i implementuj kolejną część kodu po każdym nowym teście. Nie zapomnij o ponownym przejrzeniu kodu, a także przypadków testowych.

Nadchodzi haczyk : gdy dodasz przypadki testowe dla numeru wymagania / kategorii „n”, musisz jedynie dodać testy, aby upewnić się, że wynik kategorii „n-1” jest wyższy niż wynik dla kategorii „n” . Nie będziesz musiał dodawać żadnych przypadków testowych dla każdej innej kombinacji kategorii 1, ..., n-1, ponieważ testy, które wcześniej napisałeś, upewnią się, że wyniki tych kategorii będą nadal w prawidłowej kolejności.

To da ci liczbę przypadków testowych, które rosną w przybliżeniu liniowo wraz z liczbą wymagań, a nie wykładniczo.

Doktor Brown
źródło
1
Naprawdę podoba mi się ta odpowiedź. Daje jasną i zwięzłą strategię testowania jednostek, aby podejść do tego problemu, pamiętając o TDD. Rozbijasz to całkiem ładnie.
wałek klonowy
@maple_shaft: dzięki i bardzo podoba mi się twoje pytanie. Chciałbym dodać, że myślę, że nawet przy twoim podejściu do projektowania wszystkich przypadków testowych w pierwszej kolejności klasyczna technika budowania klas równoważności dla testów może być wystarczająca do zmniejszenia wzrostu wykładniczego (ale jak dotąd tego nie wypracowałem).
Doc Brown
13

Zastanów się, czy nie napisać klasy, która przejrzy predefiniowaną listę warunków i pomnoży bieżący wynik przez 2 za każde pomyślne sprawdzenie.

Można to bardzo łatwo przetestować za pomocą zaledwie kilku próbnych testów.

Następnie możesz napisać klasę dla każdego warunku, a dla każdego przypadku są tylko 2 testy.

Naprawdę nie rozumiem twojego przypadku użycia, ale mam nadzieję, że ten przykład pomoże.

public class ScoreBuilder
{
    private ISingleScorableCondition[] _conditions;
    public ScoreBuilder (ISingleScorableCondition[] conditions)
    {
        _conditions = conditions;
    }

    public int GetScore(string toBeScored)
    {
        foreach (var condition in _conditions)
        {
            if (_conditions.Test(toBeScored))
            {
                // score this somehow
            }
        }
    }
}

public class ExactMatchOnNameCondition : ISingleScorableCondition
{
    private IDataSource _dataSource;
    public ExactMatchOnNameCondition(IDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public bool Test(string toBeTested)
    {
        return _dataSource.Contains(toBeTested);
    }
}

// etc

Zauważysz, że twoje testy warunków 2 ^ szybko sprowadzają się do 4+ (warunki 2 *). 20 jest znacznie mniej narzucające się niż 64. A jeśli dodasz kolejną później, nie musisz zmieniać ŻADNEJ z istniejących klas (zasada otwarta-zamknięta), więc nie musisz pisać 64 nowych testów, po prostu masz aby dodać kolejną klasę z 2 nowymi testami i wprowadzić ją do swojej klasy ScoreBuilder.

pdr
źródło
Ciekawe podejście Przez cały czas mój umysł nigdy nie rozważał podejścia OOP, ponieważ utknąłem w umyśle jednego komponentu porównawczego. Naprawdę nie szukałem porad dotyczących algorytmów, ale jest to bardzo pomocne, niezależnie od tego.
wałek klonowy
4
@maple_shaft: Nie, ale szukałeś porad TDD, a tego rodzaju algorytmy są idealne do usunięcia pytania, czy warto wysiłek, poprzez znaczne zmniejszenie wysiłku. Zmniejszenie złożoności jest kluczem do TDD.
pdr
+1, świetna odpowiedź. Chociaż uważam, że nawet bez tak wyrafinowanego rozwiązania liczba przypadków testowych nie musi rosnąć wykładniczo (patrz moja odpowiedź poniżej).
Doc Brown
Nie zaakceptowałem twojej odpowiedzi, ponieważ czułem, że inna odpowiedź lepiej odnosi się do rzeczywistego pytania, ale podobało mi się twoje podejście do projektowania tak bardzo, że wdrażam je zgodnie z twoimi sugestiami. Zmniejsza to złożoność i sprawia, że ​​w dłuższej perspektywie jest bardziej rozszerzalna.
wałek klonowy
4

Czy nadal warto wkładać wysiłek w napisanie każdego z tych przypadków testowych?

Musisz zdefiniować „warto”. Problem z tego rodzaju scenariuszem polega na tym, że testy będą miały coraz mniejszą użyteczność. Z pewnością pierwszy test, który napiszesz, będzie całkowicie tego wart. Może znaleźć oczywiste błędy w priorytecie, a nawet takie rzeczy jak błędy parsowania podczas próby rozbicia słów.

Drugi test będzie tego wart, ponieważ obejmuje inną ścieżkę w kodzie, prawdopodobnie sprawdzając inną relację priorytetu.

63. test prawdopodobnie nie będzie tego wart, ponieważ jest to coś, na co masz pewność 99,99%, objęte logiką Twojego kodu lub innym testem.

Czy jest to poziom testowania, który jest zwykle wymagany przy mówieniu o 100% pokryciu testami w TDD?

Rozumiem, że 100% pokrycia oznacza, że ​​wszystkie ścieżki kodu są wykonywane. Nie oznacza to, że wykonujesz wszystkie kombinacje reguł, ale wszystkie ścieżki, którymi Twój kod może pójść w dół (jak zauważyłeś, niektóre kombinacje nie mogą istnieć w kodzie). Ale ponieważ robisz TDD, nie ma jeszcze „kodu” do sprawdzenia ścieżek. List procesu powiedziałby, że wszystkie 63+.

Osobiście uważam, że 100% zasięgu to marzenie o fajce. Poza tym jest to niepragmatyczne. Istnieją testy jednostkowe, które mają ci służyć, a nie odwrotnie. Gdy wykonujesz więcej testów, zyski maleją z korzyści (prawdopodobieństwo, że test zapobiega błędowi + pewność, że kod jest poprawny). W zależności od tego, co robi twój kod, określa, gdzie na tej przesuwnej skali przestajesz wykonywać testy. Jeśli Twój kod działa na reaktorze jądrowym, być może wszystkie ponad 63 testy są tego warte. Jeśli Twój kod organizuje twoje archiwum muzyczne, prawdopodobnie możesz uciec o wiele mniej.

Telastyn
źródło
„zasięg” zazwyczaj odnosi się do pokrycia kodu (każda linia kodu jest wykonywana) lub pokrycia gałęzi (każda gałąź jest wykonywana co najmniej raz w dowolnym możliwym kierunku). W przypadku obu rodzajów pokrycia nie ma potrzeby 64 różnych przypadków testowych. Przynajmniej nie przy poważnej implementacji, która nie zawiera poszczególnych części kodu dla każdego z 64 przypadków. Zatem 100% zasięgu jest w pełni możliwe.
Doc Brown
@DocBrown - oczywiście w tym przypadku - inne rzeczy są trudniejsze / niemożliwe do przetestowania; rozważ ścieżki wyjątku braku pamięci. Czy nie wszystkie 64 byłyby wymagane w „literowym” TDD w celu wymuszenia zachowania testowanego ignorując implementację?
Telastyn
cóż, mój komentarz był związany z pytaniem, a twoja odpowiedź sprawia wrażenie, że może być trudno uzyskać 100% zasięgu w przypadku PO . Wątpię w to. Zgadzam się z tobą, że można konstruować przypadki, w których 100% pokrycia jest trudniejsze do osiągnięcia, ale nie zapytano o to.
Doc Brown
4

Twierdziłbym, że jest to idealny przypadek dla TDD.

Masz znany zestaw kryteriów do przetestowania, z logicznym podziałem tych przypadków. Zakładając, że przetestujesz je teraz lub później, wydaje się sensowne, aby wziąć znany wynik i zbudować go wokół siebie, upewniając się, że faktycznie przestrzegasz każdej reguły niezależnie.

Ponadto możesz dowiedzieć się na bieżąco, czy dodanie nowej reguły wyszukiwania narusza istniejącą regułę. Jeśli zrobisz to wszystko pod koniec kodowania, prawdopodobnie ryzykujesz koniecznością zmiany jednego, aby naprawić jeden, co psuje inny, co psuje inny ... I podczas wdrażania zasad dowiadujesz się, czy Twój projekt jest poprawny lub wymaga ulepszenia.

Wonko przy zdrowych zmysłach
źródło
1

Nie jestem fanem ścisłego interpretowania 100% zakresu testów jako pisania specyfikacji dla każdej metody lub testowania każdej permutacji kodu. Robienie tego fanatycznie prowadzi do testowego zaprojektowania klas, które nie zawierają poprawnie logiki biznesowej i dają testy / specyfikacje, które są generalnie pozbawione znaczenia pod względem opisu obsługiwanej logiki biznesowej. Zamiast tego skupiam się na konstruowaniu testów podobnie jak same reguły biznesowe i staram się wykonywać każdą warunkową gałąź kodu za pomocą testów z wyraźnym oczekiwaniem, że testy te są łatwo zrozumiałe dla testera jako ogólnie przypadki użycia i faktycznie opisują reguły biznesowe, które zostały wdrożone.

Mając to na uwadze, wyczerpująco przetestowałbym jednostkowo 6 czynników rankingowych, które wymieniliście oddzielnie, a następnie przeprowadziliśmy 2 lub 3 testy w stylu integracji, które zapewnią, że wyniki zostaną zwiększone do oczekiwanych ogólnych wartości rankingu. Na przykład, przypadek nr 1, Dokładne dopasowanie do nazwy, miałbym co najmniej dwa testy jednostkowe, aby sprawdzić, kiedy jest dokładny, a kiedy nie, i że dwa scenariusze zwracają oczekiwany wynik. Jeśli rozróżniana jest wielkość liter, to również próba sprawdzenia „Dokładnego dopasowania” vs. „ścisłego dopasowania” i ewentualnie innych odmian wejściowych, takich jak interpunkcja, dodatkowe spacje itp., Również zwraca oczekiwane wyniki.

Po przejrzeniu wszystkich poszczególnych czynników przyczyniających się do wyników rankingu, zasadniczo zakładam, że działają one poprawnie na poziomie integracji i skupiam się na zapewnieniu, że ich połączone czynniki prawidłowo przyczyniają się do ostatecznego oczekiwanego wyniku rankingu.

Zakładając, że przypadki # 2 / # 3 i # 4 / # 5 są uogólnione na te same metody bazowe, ale przekazując różne pola, wystarczy napisać tylko jeden zestaw testów jednostkowych dla podstawowych metod i napisać proste dodatkowe testy jednostkowe, aby przetestować określone pola (tytuł, nazwa, opis itp.) i punktacja w wyznaczonym faktoringu, co dodatkowo zmniejsza redundancję całego wysiłku związanego z testowaniem.

Przy takim podejściu opisane powyżej podejście prawdopodobnie dałoby 3 lub 4 testy jednostkowe dla przypadku nr 1, być może 10 specyfikacji dla niektórych / wszystkich uwzględnionych synonimów - plus 4 specyfikacje dotyczące poprawnej oceny przypadków nr 2 - nr 5 i 2 do 3 specyfikacji w rankingu uporządkowanym według ostatecznej daty, a następnie od 3 do 4 testów poziomu integracji, które mierzą wszystkie 6 przypadków łącznie w prawdopodobny sposób (na razie zapomnij o niejasnych przypadkowych przypadkach, chyba że wyraźnie widzisz problem w kodzie, który należy rozwiązać, aby zapewnić ten warunek jest obsługiwany) lub zapewnić, że nie zostanie naruszony / złamany przez późniejsze wersje. Daje to około 25 specyfikacji do wykonania 100% napisanego kodu (nawet jeśli nie wywołałeś bezpośrednio 100% zapisanych metod).

Michael Lang
źródło
1

Nigdy nie byłem fanem 100% pokrycia testowego. Z mojego doświadczenia wynika, że ​​jeśli coś jest wystarczająco proste do przetestowania za pomocą tylko jednego lub dwóch przypadków testowych, to jest wystarczająco proste, aby rzadko zawieść. Kiedy się nie powiedzie, zwykle są to zmiany architektoniczne, które i tak wymagałyby zmian testowych.

Biorąc to pod uwagę, w przypadku wymagań takich jak twoje zawsze przeprowadzam dokładne testy jednostkowe, nawet w projektach osobistych, w których nikt mnie nie zmusza, ponieważ są to przypadki, w których testy jednostkowe oszczędzają czas i pogorszenie. Im więcej testów jednostkowych jest wymaganych, aby coś przetestować, tym więcej czasu zaoszczędzą testy jednostkowe.

To dlatego, że możesz trzymać w głowie tak wiele rzeczy naraz. Jeśli próbujesz napisać kod, który działa dla 63 różnych kombinacji, często trudno jest naprawić jedną kombinację bez rozbijania drugiej. W końcu ręcznie testujesz inne kombinacje. Testy ręczne są znacznie wolniejsze, co sprawia, że ​​nie chcesz ponownie uruchamiać każdej możliwej kombinacji za każdym razem, gdy wprowadzasz zmiany. To sprawia, że ​​bardziej prawdopodobne jest, że coś przeoczysz i będziesz tracić czas na szukanie ścieżek, które nie działają we wszystkich przypadkach.

Oprócz zaoszczędzonego czasu w porównaniu z testowaniem ręcznym, obciążenie psychiczne jest znacznie mniejsze, co ułatwia skupienie się na problemie bez obawy o przypadkowe wprowadzenie regresji. Dzięki temu możesz pracować szybciej i dłużej bez wypalenia. Moim zdaniem same korzyści dla zdrowia psychicznego są warte kosztu jednostkowego testowania złożonego kodu, nawet jeśli nie zaoszczędziłoby to czasu.

Karl Bielefeldt
źródło