Jakie są dobre testy jednostkowe na wypadek użycia rzutu kostką?

18

Staram się poradzić sobie z testowaniem jednostkowym.

Załóżmy, że mamy kość, która może mieć domyślną liczbę boków równą 6 (ale może mieć 4, 5 stron itp.):

import random
class Die():
    def __init__(self, sides=6):
        self._sides = sides

    def roll(self):
        return random.randint(1, self._sides)

Czy poniższe testy byłyby ważne / przydatne?

  • przetestuj rzut w zakresie 1-6 dla kostki 6-stronnej
  • przetestuj rzut 0 dla 6-stronnej kostki
  • przetestuj rzut 7 dla kostki 6-stronnej
  • przetestuj rzut w zakresie 1-3 dla matrycy 3-stronnej
  • przetestuj rzut 0 dla 3-stronnej kości
  • przetestuj rzut 4 dla 3-stronnej kostki

Po prostu myślę, że to strata czasu, ponieważ moduł losowy istnieje już wystarczająco długo, ale myślę, że jeśli moduł losowy zostanie zaktualizowany (powiedzmy, że aktualizuję wersję Pythona), to przynajmniej jestem objęty.

Czy muszę nawet testować inne odmiany rolek matrycy, np. 3 w tym przypadku, czy też dobrze jest pokryć inny zainicjowany stan matrycy?

Cybran
źródło
1
Co z kością minus 5-stronną lub kością zerową?
JensG

Odpowiedzi:

22

Masz rację, twoje testy nie powinny sprawdzać, czy randommoduł wykonuje swoją pracę; unittest powinien testować tylko samą klasę, a nie sposób interakcji z innym kodem (który powinien być testowany osobno).

Jest oczywiście całkowicie możliwe, że Twój kod używa random.randint()niewłaściwie; lub random.randrange(1, self._sides)zamiast tego dzwonisz, a twoja kość nigdy nie rzuca najwyższej wartości, ale byłby to inny rodzaj błędu, który nie byłby w stanie złapać przy najmniejszym poziomie. W takim przypadku die urządzenie działa zgodnie z przeznaczeniem, ale sam projekt był wadliwy.

W tym przypadku użyłbym wyśmianie aby wymienić się randint()funkcji, a jedynie sprawdzić, czy został on nazywany poprawnie. Python 3.3 i nowsze wersje są dostarczane z unittest.mockmodułem do obsługi tego typu testów, ale można zainstalować mockpakiet zewnętrzny na starszych wersjach, aby uzyskać dokładnie taką samą funkcjonalność

import unittest
try:
    from unittest.mock import patch
except ImportError:
    # < python 3.3
    from mock import patch


@patch('random.randint', return_value=3)
class TestDice(unittest.TestCase):
    def _make_one(self, *args, **kw):
        from die import Die
        return Die(*args, **kw)

    def test_standard_size(self, mocked_randint):
        die = self._make_one()
        result = die.roll()

        mocked_randint.assert_called_with(1, 6)
        self.assertEqual(result, 3)

    def test_custom_size(self, mocked_randint):
        die = self._make_one(sides=42)
        result = die.roll()

        mocked_randint.assert_called_with(1, 42)
        self.assertEqual(result, 3)


if __name__ == '__main__':
    unittest.main()

Dzięki drwiom test jest teraz bardzo prosty; tak naprawdę są tylko 2 przypadki. Domyślny przypadek dla 6-stronnej matrycy i niestandardowy przypadek po bokach.

Istnieją inne sposoby tymczasowego zastąpienia randint()funkcji w globalnej przestrzeni nazw Die, ale mockmoduł czyni to najłatwiejszym. @mock.patchDekorator tutaj odnosi się do wszystkich metod badań w przypadku badania; do każdej metody testowej przekazywany jest dodatkowy argument - wyśmiewana random.randint()funkcja, dzięki czemu możemy testować przeciwko próbce, aby sprawdzić, czy rzeczywiście została poprawnie wywołana. W return_valueOkreśla argumentów co wrócił z mock kiedy to się nazywa, więc możemy sprawdzić, czy die.roll()metoda rzeczywiście wrócił „random” rezultat do nas.

Użyłem tutaj kolejnej najlepszej praktyki Pythona: zaimportuj testowaną klasę w ramach testu. _make_oneMetoda działa importowanie i konkretyzacji w teście , tak że test moduł nadal będzie ładować nawet jeśli popełnił błąd składni lub inny błąd, który będzie zapobiegać oryginalny moduł do importu.

W ten sposób, jeśli popełnisz błąd w samym kodzie modułu, testy będą nadal uruchamiane; po prostu zawiodą, informując o błędzie w kodzie.

Dla jasności powyższe testy są wyjątkowo uproszczone. Naszym celem nie jest na przykład testowanie random.randint()przy użyciu odpowiednich argumentów. Zamiast tego celem jest przetestowanie, czy jednostka daje prawidłowe wyniki przy określonych danych wejściowych, przy czym dane wejściowe obejmują wyniki innych jednostek, które nie są testowane. Wyśmiewając random.randint()metodę, możesz przejąć kontrolę nad kolejnymi danymi wejściowymi do swojego kodu.

W rzeczywistych testach rzeczywisty kod w testowanym urządzeniu będzie bardziej złożony; związek z danymi wejściowymi przekazywanymi do interfejsu API i sposób wywoływania innych jednostek może być nadal interesujący, a kpina zapewni dostęp do wyników pośrednich, a także umożliwi ustawienie wartości zwracanych dla tych wywołań.

Na przykład w kodzie, który uwierzytelnia użytkowników na podstawie usługi OAuth2 innej firmy (interakcja wieloetapowa), chcesz przetestować, czy Twój kod przekazuje odpowiednie dane do tej usługi innej firmy i pozwala wyśmiewać różne odpowiedzi na błędy, które Usługa innej firmy powróci, umożliwiając symulację różnych scenariuszy bez konieczności samodzielnego budowania pełnego serwera OAuth2. W tym miejscu ważne jest przetestowanie, czy informacje z pierwszej odpowiedzi zostały poprawnie obsłużone i zostały przekazane do wywołania drugiego etapu, więc chcesz zobaczyć, czy fałszywa usługa jest wywoływana poprawnie.

Martijn Pieters
źródło
1
Masz całkiem więcej niż 2 przypadki testowe ... wyniki sprawdzają wartość domyślną: dolna (1), górna (6), poniżej dolna (0), poza górna (7) i wyniki dla liczb określonych przez użytkownika, takich jak max_int itp. dane wejściowe również nie są sprawdzane, co może wymagać przetestowania w pewnym momencie ...
James Snell
2
Nie, to są testy randint(), a nie kod w Die.roll().
Martijn Pieters
W rzeczywistości istnieje sposób, aby upewnić się, że nie tylko Randint jest wywoływany poprawnie, ale że jego wynik jest również używany poprawnie: wyśmiewaj się, aby zwrócić sentinel.diena przykład (obiekt wartownika unittest.mockteż), a następnie sprawdź, czy to, co zostało zwrócone z metody rzutu. To faktycznie pozwala tylko na jeden sposób implementacji testowanej metody.
aragaer
@aragaer: na pewno, jeśli chcesz sprawdzić, czy wartość jest zwracana w postaci niezmienionej, sentinel.diebyłby to świetny sposób, aby to zapewnić.
Martijn Pieters
Nie rozumiem, dlaczego chcesz się upewnić, że mocked_randint nazywa się_z pewnymi wartościami. Rozumiem, że chcę wyśmiewać Randintona, aby zwrócić przewidywalne wartości, ale czy nie chodzi tylko o to, że zwraca przewidywalne wartości, a nie o jakie wartości jest wywoływany? Wydaje mi się, że sprawdzanie wywoływanych wartości niepotrzebnie wiąże test z drobnymi szczegółami implementacji. Również dlaczego zależy nam na tym, aby kostka zwracała dokładną wartość randinta? Czy nie obchodzi nas to, że zwraca wartość> 1 i mniejszą niż wartość maksymalna?
bdrx,
16

Odpowiedź Martijna brzmi: jak byś to zrobił, gdybyś naprawdę chciał przeprowadzić test, który pokazuje, że nazywasz się random.randint. Jednak ryzykując powiedzenie „to nie odpowiada na pytanie”, uważam, że nie powinno to być w ogóle testowane jednostkowo. Szyderczy randint nie jest już testowaniem czarnych skrzynek - konkretnie pokazujesz, że pewne rzeczy dzieją się podczas implementacji . Testowanie czarnej skrzynki nie jest nawet opcją - nie można wykonać testu, który udowodni, że wynik nigdy nie będzie mniejszy niż 1 lub większy niż 6.

Umiesz kpić randint? Tak, możesz. Ale co udowadniasz? Że nazwałeś to argumentami 1 i stronami. Co to oznacza? Wróciłeś do punktu wyjścia - pod koniec dnia musisz udowodnić - formalnie lub nieoficjalnie - że random.randint(1, sides)prawidłowe sprawdzenie powoduje rzut kostką.

Jestem za testami jednostkowymi. Są to fantastyczne kontrole poczytalności i ujawniają obecność błędów. Jednak nigdy nie mogą udowodnić swojej nieobecności, a są rzeczy, których nie można w ogóle potwierdzić podczas testowania (np. Że określona funkcja nigdy nie zgłasza wyjątku lub zawsze kończy się.) W tym konkretnym przypadku czuję, że niewiele jest do zaoferowania zdobyć. W przypadku deterministycznego zachowania testy jednostkowe mają sens, ponieważ faktycznie wiesz, jakiej odpowiedzi oczekujesz.

Doval
źródło
Testy jednostkowe nie są tak naprawdę testami czarnej skrzynki. Do tego służą testy integracyjne, aby upewnić się, że poszczególne części oddziałują zgodnie z przeznaczeniem. Oczywiście jest to kwestia opinii (większość filozofii testowania jest taka), patrz Czy „Testowanie jednostkowe” mieści się w zakresie testów białych lub czarnych skrzynek? oraz testowanie jednostek czarnej skrzynki dla niektórych perspektyw (przepełnienie stosu).
Martijn Pieters
@MartijnPieters Nie zgadzam się, że „po to są testy integracyjne”. Testy integracyjne służą do sprawdzenia, czy wszystkie komponenty systemu działają poprawnie. Nie są one miejscem, w którym można sprawdzić, czy dany komponent zapewnia poprawne wyjście dla danych wejściowych. Jeśli chodzi o testowanie czarnych skrzynek w porównaniu z białymi skrzynkami, testy białych skrzynek ostatecznie zerwą ze zmianami implementacyjnymi, a wszelkie założenia przyjęte podczas implementacji prawdopodobnie zostaną przeniesione do testu. Sprawdzanie poprawności random.randintwywołanej za pomocą 1, sidesjest bezwartościowe, jeśli jest to niewłaściwa rzecz.
Doval
Tak, to ograniczenie testu jednostronnego. Jednak nie ma sensu testować, random.randint()które poprawnie zwracają wartości z zakresu [1, strony] (włącznie), to zależy od programistów Pythona, aby upewnić się, że randomjednostka działa poprawnie.
Martijn Pieters
I jak sam twierdzisz, testy jednostkowe nie mogą zagwarantować, że twój kod jest wolny od błędów; jeśli kod używa innych jednostek w niewłaściwy sposób (powiedzmy, że spodziewałeś random.randint()się zachowywać jak random.randrange()i wywoływać go random.randint(1, sides + 1), to i tak jesteś zatopiony.
Martijn Pieters
2
@MartijnPieters Zgadzam się z tobą, ale nie do tego się sprzeciwiam. Sprzeciwiam się testowaniu tego losowego. Randint jest wywoływany z argumentami (1, strony) . W implementacji założyłeś, że jest to właściwa rzecz, a teraz powtarzasz to założenie w teście. Jeśli to założenie jest błędne, test przejdzie pomyślnie, ale implementacja jest nadal niepoprawna. Jest to półprawdziwy dowód, który jest trudny do napisania i utrzymania.
Doval
6

Napraw losowe ziarno. W przypadku kości 1, 2, 5 i 12-stronnych potwierdź, że kilka tysięcy rzutów daje wyniki, w tym 1 i N, a nie 0 lub N + 1. Jeśli z pozoru dziwna szansa, otrzymasz zestaw losowych wyników, które nie pokrywamy oczekiwany zakres, przełączamy na inne nasiona.

Narzędzia kpienia są fajne, ale to, że pozwalają ci coś zrobić, nie oznacza, że ​​należy to zrobić. YAGNI ma zastosowanie zarówno do urządzeń testowych, jak i do funkcji.

Jeśli możesz łatwo przetestować przy użyciu nieopartych zależności, prawie zawsze powinieneś; w ten sposób twoje testy będą koncentrować się na zmniejszeniu liczby wad, a nie tylko na zwiększeniu liczby testów. Nadmierne kpiny grożą stworzeniem mylących danych dotyczących zasięgu, co z kolei może prowadzić do przełożenia faktycznych testów na późniejszą fazę, której być może nigdy nie zdążycie ...

soru
źródło
3

Co się stanie, Diejeśli o tym pomyślisz? - nie więcej niż opakowanie random. To obudowuje random.randinti relabels go w kategoriach własnego słownictwa danej aplikacji: Die.Roll.

Nie wydaje mi się istotne, aby wstawiać kolejną warstwę abstrakcji pomiędzy Diei randomponieważ Diesama jest już tą warstwą pośrednictwa między twoją aplikacją a platformą.

Jeśli chcesz wyniki w kostkach z puszki, po prostu kpij Die, nie kpijrandom .

Zasadniczo nie testuję jednostkowo moich obiektów opakowania komunikujących się z systemami zewnętrznymi, piszę dla nich testy integracyjne. Możesz napisać kilka takich, Dieale jak wskazałeś, ze względu na losowy charakter obiektu leżącego u podstaw, nie będą one miały znaczenia. Ponadto nie ma tu potrzeby konfiguracji ani komunikacji sieciowej, więc nie trzeba wiele testować poza połączeniem z platformą.

=> Biorąc pod uwagę, że Diejest to tylko kilka trywialnych wierszy kodu i niewiele dodaje żadnej logiki w porównaniu do randomsamej siebie, pominę testowanie jej w tym konkretnym przykładzie.

guillaume31
źródło
2

Rozsiewanie generatora liczb losowych i weryfikacja oczekiwanych wyników NIE jest, o ile widzę, poprawnym testem. Przyjmuje założenia, w jaki sposób kości działają wewnętrznie, co jest niegrzeczne-niegrzeczne. Twórcy Pythona mogą zmienić generator liczb losowych lub kostkę (UWAGA: „kostki” są w liczbie mnogiej, „kostka” jest pojedyncza. O ile twoja klasa nie wykonuje wielu rzutów kostką w jednym wywołaniu, prawdopodobnie powinna być nazywana „kostką”) użyj innego generatora liczb losowych.

Podobnie, wyśmiewanie funkcji losowej zakłada, że ​​implementacja klasy działa dokładnie zgodnie z oczekiwaniami. Dlaczego może tak nie być? Ktoś może przejąć kontrolę nad domyślnym generatorem liczb losowych w Pythonie i aby tego uniknąć, przyszła wersja twojej kości może pobrać kilka liczb losowych lub większe liczby losowe, aby zmieszać więcej danych losowych. Podobny schemat zastosowali twórcy systemu operacyjnego FreeBSD, gdy podejrzewali, że NSA manipuluje sprzętowymi generatorami liczb losowych wbudowanymi w procesory.

Gdybym to był ja, pobiegłbym, powiedzmy, 6000 rolek, zliczając je i upewniając się, że każda liczba od 1-6 jest wyrzucana między 500 a 1500 razy. Sprawdziłbym również, czy nie są zwracane liczby spoza tego zakresu. Mogę również sprawdzić, czy dla drugiego zestawu 6000 rolek przy zamówieniu [1..6] w kolejności częstotliwości wynik jest inny (nie powiedzie się to raz na 720 uruchomień, jeśli liczby są losowe!). Jeśli chcesz być dokładny, możesz znaleźć częstotliwość liczb po 1, po 2 itd. ale upewnij się, że rozmiar próbki jest wystarczająco duży i masz wystarczającą wariancję. Ludzie oczekują, że liczby losowe będą miały mniej wzorów niż w rzeczywistości.

Powtórz dla matrycy 12-stronnej i 2-stronnej (najczęściej używana jest 6, więc jest najbardziej oczekiwana dla każdego, kto pisze ten kod).

Na koniec chciałbym przetestować, co się dzieje z jednostronną matrycą, matrycą 0-stronną, matrycą 1-stronną, matrycą 2,3-stronną, matrycą [1,2,3,4,5,6], oraz jednostronna śmierć. Oczywiście wszystko to powinno zawieść; czy zawodzą w pożyteczny sposób? Powinny one prawdopodobnie zawieść przy tworzeniu, a nie przy toczeniu.

A może chcesz też traktować je inaczej - być może tworzenie kości z [1,2,3,4,5,6] powinno być dopuszczalne - a być może „bla”; może to być kość z 4 twarzami, a każda twarz ma na sobie literę. Przychodzi na myśl gra „Boggle”, podobnie jak magiczna ósemka.

I na koniec warto rozważyć: http://lh6.ggpht.com/-fAGXwbJbYRM/UJA_31ACOLI/AAAAAAAAAAPg/2FxOWzo96KE/s1600-h/random%25255B3%25255D.jpg

AMADANON Inc.
źródło
2

Ryzykując pływanie pod prąd, rozwiązałem ten dokładny problem wiele lat temu, stosując metodę, o której jeszcze nie wspomniano.

Moją strategią było po prostu wyśmiewanie RNG za pomocą takiego, który wytwarza przewidywalny strumień wartości obejmujący całą przestrzeń. Jeśli (powiedzmy) strona = 6, a RNG generuje wartości od 0 do 5 w sekwencji, mogę przewidzieć, jak powinna się zachowywać moja klasa i odpowiednio przetestować jednostkę.

Uzasadnieniem jest to, że testuje to logikę tylko w tej klasie, przy założeniu, że RNG ostatecznie wytworzy każdą z tych wartości i bez testowania samej RNG.

Jest prosty, deterministyczny, powtarzalny i łapie błędy. Użyłbym tej samej strategii ponownie.


Pytanie nie precyzuje, jakie powinny być testy, tylko jakie dane mogą być użyte do testowania, biorąc pod uwagę obecność RNG. Moją sugestią jest jedynie wyczerpujące przetestowanie kpiny z RNG. Pytanie o to, co warto przetestować, zależy od informacji, których nie podano w pytaniu.

david.pfx
źródło
Załóżmy, że kpisz z RNG, aby być przewidywalnym. Co więc testujesz? Pytanie brzmi: „Czy poniższe testy byłyby prawidłowe / przydatne?” Wyśmiewanie go, aby zwróciło 0-5, nie jest testem, ale raczej konfiguracją testową. Jak byś „odpowiednio przetestował jednostkę”? Nie rozumiem, w jaki sposób „łapie błędy”. Trudno mi zrozumieć, co jest potrzebne do testu „jednostkowego”.
bdrx
@bdrx: To było jakiś czas temu: odpowiedziałbym teraz inaczej. Ale zobacz edycję.
david.pfx
1

Testy, które sugerujesz w swoim pytaniu, nie wykrywają modułowego licznika arytmetycznego jako implementacji. I nie wykrywają typowych błędów implementacyjnych w kodzie związanym z rozkładem prawdopodobieństwa return 1 + (random.randint(1,maxint) % sides). Lub zmiana generatora, która powoduje powstanie dwuwymiarowych wzorów.

Jeśli naprawdę chcesz sprawdzić, czy generujesz równomiernie rozmieszczone losowo wyglądające liczby, musisz sprawdzić bardzo szeroką gamę właściwości. Aby wykonać dość dobrą robotę, możesz uruchomić http://www.phy.duke.edu/~rgb/General/dieharder.php na wygenerowanych liczbach. Lub napisz podobnie złożony zestaw testów jednostkowych.

To nie wina testów jednostkowych ani TDD, przypadkowość jest po prostu bardzo trudną do zweryfikowania właściwością. I popularny temat przykładów.

Patrick
źródło
-1

Najłatwiejszym testem rzutu jest powtórzenie go kilkaset tysięcy razy i sprawdzenie, czy każdy możliwy wynik trafił z grubsza (1 / liczbę stron) razy. W przypadku 6-stronnej kości powinieneś zobaczyć każdą możliwą wartość uderzoną przez około 16,6% czasu. Jeśli jakieś są wyłączone o więcej niż jeden procent, masz problem.

Robiąc to w ten sposób, unikasz możliwości refaktoryzacji podstawowej mechaniki generowania liczb losowych łatwo, a co najważniejsze, bez zmiany testu.

ChristopherBrown
źródło
1
test ten przejdzie do zupełnie nieprzypadkowej implementacji, która po prostu zapętla strony jeden po drugim w ustalonej kolejności
gnat
1
Jeśli programista zamierza wdrożyć coś w złej wierze (nie używając losowego agenta na kości) i po prostu próbuje znaleźć coś, co „sprawi, że czerwone światła zmienią kolor na zielony”, masz więcej problemów, niż testowanie jednostkowe może naprawdę rozwiązać.
ChristopherBrown