Autor Wątek: Mock funkcji w tym samym pliku  (Przeczytany 3000 razy)

Offline Lerhes

  • Użytkownik

  • +1
# Czerwiec 27, 2015, 19:01:06
Uwaga: Pytanie ma niewiele wspólnego z samymi grami, aczkolwiek odpowiedź na nie może się przydać i w tej branży.

Krótki wstęp: Życie programisty C/C++ jest trudne :). Nie mam na myśli problemów ze znalezieniem pracy ale jej rodzaj. Bardzo często wiąże się ona z pracą z tak zwanym Legacy Code - miliony linii kodu bez jednego testu. Korzysta z tego kodu tysiące klientów - więc wiadomo nie można go "zepsuć". Kod ten tworzyło setki programistów, każdy miał inne doświadczenie i nawyki. Brak struktury projektu, jednolitego formatowania - jeden problem jest rozwiązywany na wiele sposobów, każdy z nich inny.
Trudno mi powiedzieć, czy to tylko moje doświadczenia, czy prawie każdy mógłby się pod tym podpisać.

Skupmy się jednak na problemie: Niejaki Michael Feathers napisał książkę (Working Effectively with Legacy Code) która próbuje jakoś odpowiedzieć na pytanie: Co z tym Legacy Code? Zaznajomiłem się z nią i staram się stosować przedstawione w niej metody pracy z kodem. Wszystko trzeba jednak osadzić w realiach zarządzania firmą - mój przełożony zgodził się, żebym sobie pracował z kodem tak jak lubię - ale żeby zmiany w plikach źródłowych kodu produkcyjnego były możliwie najmniejsze (najlepiej nic nie zmieniać jeśli nie trzeba). To jest ważne ograniczenie z mojej perspektywy.

Testowanie idzie nawet sprawnie. Dodawanie nowych funkcjonalności wspierane przez testy jest naprawdę produktywne i skutecznie. Większość moich zmian robi to co powinno praktycznie za pierwszym razem i bardzo rzadko przy tych modyfikacjach uszkodzeniu ulegają inne części systemu. Fajnie. Natomiast mam problem z wprowadzaniem testów do kodu który jest bardzo mocno napisany w C (są i takie fragmenty systemu).

Konkretnie z czym mam problem: Jak testować/refaktoryzować kod w C. Jest nawet książka która adresuje ten problem: "Test Driven Development for Embedded C" - przeglądnąłem ją tylko, ale jakoś nie znalazłem w niej rozwiązania mojego problemu. Oto jak wygląda przykładowy kod który zmieniam:

Plik: funkcjonalnoc.h
void funkcjaKtoraRobiCosWaznego(int a, strukturaJakas b);
Plik: funkcjonalnosc.cpp

static int global_1;
static int blobal_2;

<tutaj 100 innych globali>

void funkcjaKtoraRobiCosWaznego(int a, strukturaJakas b)
{
int zmiennaLokalna = 14;
<1000 linii kodu>
global_1 = 12;

if ( funkcjaLokalnaDlaTegoPliku(zmiennaLokalna ) )
{
//Tutaj dużo kodu
}

<2000 linii kodu>

funkcjaWInnymPliku(zmiennaLokalna);

}


static void funkcjaLokalnaDlaTegoPliku(zmiennaLokalna )
{
<tutaj znowu 1000 linii kodu>
}


Ok, tak wygląda mniej więcej kod. Teraz muszę zmienić implementację w funkcjaKtoraRobiCosWaznego. Zanim się tym zajmę, muszę trochę zrefaktoryzować tę funkcję - najpierw ją przetestować, żeby być pewnym że nic nie psuję, a potem zrefaktoryzować.

Nic prostszego:

Plik: Test.cpp
#include "potrzebnyNaglowek.h"

//Miejsce X

#include "funkcjonalnosc.cpp"

void funkcjaWInnymPliku(int zmiennaLokalna)
{
Expect(zmiennaLokalna, 150);
}

Test1()
{
strukturaJakas tmp;
tmp.pole = 15;
funkcjaKtoraRobiCosWaznego(100, tmp);
Expect(blobal_2, 12);
}

To tylko zarys - tak naprawdę to korzystam z google mock, ale to nie ma dużego znaczenia. Zwracam uwagę, że robię include na pliku cpp, dzięki temu mam dostęp do zmienny statycznych globalnych, a dodatkowo mogę robić sprytne definy jeżeli ich potrzebuję. Testowanie funkcjaKtoraRobiCosWaznego idzie jak złoto, powoli jestem gotowy do refaktoryzacji a tutaj nagle klops. Nie wiem jak "podmienić" zachowanie funkcjaLokalnaDlaTegoPliku. Jest zdefiniowana w tym samym pliku...

Jeśli w: "//Mijesce X" wstawię:
#define funkcjaLokalnaDlaTegoPliku funkcjaLokalnaDlaTegoPliku_STUB
nic mi to nie pomoże, bo zmieni się nazwa w miejscu wywołania i definicji.

Sporo czasu grzebałem w google, szukałem w książkach, ale żadne rozwiązanie mi się nie spodobało, np:
http://stackoverflow.com/questions/4882029/unit-test-stub-c-helper-method
http://stackoverflow.com/questions/8959132/function-mocking-for-testing-in-c
Working Effectively with Legacy Code - rozdział 19.

Generalnie wyszło mi, że tego problemu nie da się rozwiązać przez sprytne makro albo na poziomie linkera (dodam że korzystam z google test a projekt jest w Visual Studio). Jedyne wyjście to zmodyfikować plik: funkcjonalnosc.cpp (kod produkcyjny). Pierwsze pytanie: Czy to faktycznie prawda? Ktoś miał podobny problem i jakoś sprytnie sobie z tym poradził?

Ok załóżmy, że chcemy przerobić funkcjonalnosc.cpp, - jak to zrobić żeby móc "podmienić" implementację w funkcjaLokalnaDlaTegoPliku w teście. Można na przykład przekazać ją jako wskaźnik do funkcji, ale to nie jest dobre rozwiązanie. Takich funkcji do przekazania było by bardzo dużo, a do tego chcę żeby było ich jeszcze więcej (chcę powydzielać implementację do nowych funkcji).

Można dopisać do wywołania: funkcjaLokalnaDlaTegoPliku_ORG i w kodzie produkcyjnym:
#define _ORG
a w kodzie testowym:
#define _ORG _STUB
ale takie _ORG dziwnie będzie wyglądało w kodzie produkcyjnym. Mogę też wrzucić wszystkie te funkcje do klasy - już to sprawdziłem - działa niby fajnie, ale to dość duża modyfikacja w funkcjonalnosc.cpp. Taka zmiana jest dopuszczalna, bo cały kod jest kompilowany jako kod C++ (i testowy i produkcyjny) - ale czy to dobry pomysł ? Funkcje w tej klasie nadal korzystają z globalnych zmiennych. Można by te globalne zmienne wrzucić do klasy - ale to temat na innego posta.
Jeszcze inną opcją jest wyrzucenie funkcjaLokalnaDlaTegoPliku do innego pliku. Wtedy automatycznie stanie się zewnętrzną zależnością i będę mógł ją podmienić. Ale to nie jest dobre rozwiązanie, bo funkcjaLokalnaDlaTegoPliku jest mocno związana z funkcjaKtoraRobiCosWaznego (funkcja lokalna wykonuje pracę dla funkcji która robi coś ważnego - powinny być w jednym pliku bo realizują jedno zadanie i łatwiej zrozumieć co się dzieje gdy są blisko siebie )

Tutaj drugie pytanie: Jak wy to robicie? Jak przerobić funkcjonalnosc.cpp żeby móc wygodnie "podmienić" zachowanie funkcjaLokalnaDlaTegoPliku w teście. Przypominam, że chciałbym możliwie jak najmniej zmieniać plik funkcjonalnosc.cpp.

Jeżeli jest taka potrzeba, mogę dostarczyć bardziej "realne" fragmenty kodu - w sensie budujący się projekt który można sprawdzić. Zależy mi na konkretnych propozycjach rozwiązania - fragmenty kodu mile widziane.
Jeżeli ktoś ma ciekawy materiał na ten temat (ale nie generalnie o testowaniu tylko z rozwiązaniem tego konkretnego problemu) w internecie - proszę o linka.

Przepraszam za objętość tego wpisu.

Pozdrawiam,
Lerhes

Offline Mr. Spam

  • Miłośnik przetworów mięsnych

Offline bies

  • Użytkownik

# Czerwiec 27, 2015, 19:31:49
Hmmm...#ifndef INSIDE_TESTS
void function() {
  // production
}
#else
void function() {
  // mockup
}
#endif
?

Offline Lerhes

  • Użytkownik

# Czerwiec 27, 2015, 19:54:20
Bies, dziękuję za odpowiedź.

Rozumiem, że taki define miałby być w kodzie produkcyjnym, coś na kształt:

void funkcjaKtoraRobiCosWaznego(int a, strukturaJakas b)
{
int zmiennaLokalna = 14;
<1000 linii kodu>
global_1 = 12;
if ( funkcjaLokalnaDlaTegoPliku(zmiennaLokalna ) )
{
//Tutaj dużo kodu
}
<2000 linii kodu>
funkcjaWInnymPliku(zmiennaLokalna);
}

#ifndef INSIDE_TESTS
static void funkcjaLokalnaDlaTegoPliku(zmiennaLokalna )
{
<tutaj znowu 1000 linii kodu>
}
#endif

To powyżej było by nawet lepsze od tego co podałeś, bo w twoim przykładzie kod testu (mockup) znalazłby się w kodzie produkcyjnym. Z tym przykładem co ja podałem, miałbym po prostu do napisania stub w teście. Ok wydaje się, że jest to rozwiązanie problemu który opisałem.
Nie ma szans, żebym mógł to zastosować :(. Po pierwsze - bardzo musiałbym pozmieniać kod produkcyjny - obstawić każdą funkcję w tym pliku takimi ifdefami (funkcji takich jak funkcjaLokalnaDlaTegoPliku (związanych z funkcjaKtoraRobiCosWaznego) jest wiele). Do tego musiałbym dawać im różną nazwę, bo czasami jakąś funkcję chcę testować, innym razem chcę podstawić własną implementację - definey są niewygodne i mało elastyczne (wiele projektów do testowania jednego pliku). Już nie wspominając, że na review od razu było by pytanie - a po co Ci: #ifndef INSIDE_TESTS ? Tylko na potrzeby testów? No way!

To jest rozwiązanie, ale nadal szukam jakiegoś bardziej elastycznego. I nie chciałbym wprowadzać takich #ifdef TEST w kodzie produkcyjnym.

Lerhes

Offline bies

  • Użytkownik

# Czerwiec 27, 2015, 19:58:42
Jeśli nie chcesz zmieniać kodu produkcyjnego to na etapie kompilacji testów dodaj dodatkowy preprocesor (sed w najprostszej postaci => s/static void funkcjaLokalnaDlaTegoPliku/static void funkcjaLokalnaDlaTegoPlikuOFF/) który wyrzuć kod produkcyjny który aktualnie mockupujesz. W C++ jest łatwo takie rzeczy zrobić -- to są pliki tekstowe.
« Ostatnia zmiana: Czerwiec 27, 2015, 20:04:41 wysłana przez bies »

Offline Xion

  • Redaktor
    • xion.log

# Czerwiec 27, 2015, 20:24:33
Zanim przejdę do problemu technicznego, pozwolę sobie zwrócić uwagę na zupełnie inny, ważniejszy:
Cytuj
Brak struktury projektu, jednolitego formatowania - jeden problem jest rozwiązywany na wiele sposobów, każdy z nich inny.
Cytuj
mój przełożony zgodził się, żebym sobie pracował z kodem tak jak lubię - ale żeby zmiany w plikach źródłowych kodu produkcyjnego były możliwie najmniejsze (najlepiej nic nie zmieniać jeśli nie trzeba)
Cytuj
Już nie wspominając, że na review od razu było by pytanie - a po co Ci: #ifndef INSIDE_TESTS ? Tylko na potrzeby testów? No way!
Moja rada: uciekaj stamtąd jak najprędzej. Ta firma ma połamaną kulturę. Wielki kod wcale nie musi być kupą błota, przed którą należy drżeć wprowadzając jakiekolwiek zmiany. "Miliony linii" i "setki programistów" nie są żadną wymówką; sam pracuję przy kodzie który przerasta obie te metryki o rzędy wielkości i nie powoduje to u nikogo przemożnej trwogi.

Wracając aczkolwiek do problemu testowania. Bies radzi dobrze. Prawdopodobnie nie potrzebujesz obstawiać #ifdefami każdej funkcji, bo takie ilości mockowania to jedna z oznak tego, że twoje testy są/będą zbyt mocno związane ze strukturą kodu.
Testuj większe logiczne kawałki najpierw; być może nawet będziesz to zrobić czysto (test input -> assert output, bez żadnych mocków). Gdy w ten sposób zapewnisz, że dany kawałek "działa", możesz zacząć rozbijać go na mniejsze części. I tak, używam "działa" w cudzysłowie, bo zwykle nie będziesz miał komfortowej pewności -- lecz między innymi po to są QA, staging, etc. (Ponownie, jeśli management mówi "no way", to znaczy że ci po prostu nie ufają, że wykonujesz swoją pracę dobrze -- kolejny powód, że zawijać się stamtąd jak najprędzej).

Generalnie życzę powodzenia, bo połączenie tych dwóch problemów (technicznego i politycznego) które masz tu zaserwowane do przyjemnych z pewnością nie należy ;/

Offline ArekBal

  • Użytkownik

# Czerwiec 27, 2015, 22:32:50
[CHAMÓWA ON]

Xion: Co innego nowy projekt z grupą ekspertów, architektem i zestawem praktyk rozpisanych w dokumentach wewnętrznych, a co innego..

1. Projekt rozpoczęty przez studentów... niestety sprzedał się z sukcesem.

2. Potem siadają do tego średnio doświadczeni ludzie. Próbują to refaktoryzować, wychodzi porażka, wielki fuck-up w postaci telefonów od klientów do "CEO"... "developerzy" mówią że trzeba przepisać, nie da się refaktoryzować... bo będzie takich fuck-upów więcej.

3. Przepisują na podobne gówno... które i tak nie spełnia funkcjonalności poprzedniego systemu(a "CEO" i klienci nie chcą używać nowego systemu, bo nie ma w nim funkcjonalności które były dodawane do starego przez ostatnich 12 lat, a które z dzisiejszą praktyką biznesową nie mają za wiele wspólnego), więc mamy dwa gówna - stare i nowe.

4. Siadają eksperci(no przyjmijmy, że to ludzie, którzy wcześniej w innych firmach byli jako wyżej wymienieni, no mają doświadczenie z dłubaniem w gównie)... i piszą trzeci system... jedyna różnica taka, że nie patrzą na stare systemy... tylko robią analizę biznesową, tego co naprawdę na samym końcu klient potrzebuje, by realizować swój biznes należycie i by to technicznie przystawało do dzisiejszych realiów. Ale do tego trzeba przekonać i klientów i "CEO" i czasami jeszcze klientów klientów i klientów klientów klientów.

Jeśli "CEO" rzuca hasłem: "trzeba zmienić to i tamto", a programiści mówią - albo w praktyce wychodzi - że to zajmie za dużo, że trzeba "świętego refaktoringu"... to znaczy że wszyscy są już w dupie, ale jeszcze to do nich nie dotarło.

Ten "refactor" to integralna część pracy... Działa ci? To zanim zaczniesz następne posprzątaj po sobie. Zasada dobrego skauta/harcerza sprawdza się tu wyśmienicie.

Refaktoryzowanie molochów = przepisanie systemu, jak dobry moloch, rozbity na podsystemy to przepisanie podsystemów.

Najfajniejsze sytuacje to takie jak dev devowi serdecznie posprząta kod, zanim tamten zdąży po sobie posprzątać, taki potem patrzy i się dziwuje.


A co do testów...
By testy miały sens(bez "false positive" - tobie przechodzi wszystkie testy, nic się nie wywala, ale rozwiązanie jest bez sensu z biznesowego punktu widzenia), potrzebna dobra specka. Do takiego "legacy" zazwyczaj nie ma dobrej specki... jakby była dobra specka, dobrze odzwierciedlająca realia, to by kod nie był "legacy", a przewertowanie specki, wprowadzenie zmian i przepisanie byłoby przyjemnością. ;)
[CHAMÓWA OFF]

Myślę, że to historia nie jednej polskiej - i nie tylko - firmy IT.

Podsumowując:
IMHO refaktor istniejących systemów(starych i duzych - bo o takich mówimy), prawie zawsze będzie droższy, niż pisanie na nowo(pod warunkiem że nowy projekt tworzą/prowadzą osoby które "już" znają się na rzeczy, nie pchają się w błyskotki).

Wyjątkiem są systemy systemów z dobrym rozdzieleniem... taki projekt można dłużej pociągnąć, bo większość refaktoru to tylko przepisanie podsystemu.
« Ostatnia zmiana: Czerwiec 27, 2015, 22:36:54 wysłana przez ArekBal »

Offline Lerhes

  • Użytkownik

# Czerwiec 27, 2015, 22:53:18
Bies - dziękuję bardzo za to rozwiązanie. Mogę to zrobić w "Custom Build Steps" visual studio. Ten twój przykład z "s/cos/cos innego" to chyba z vim jeszcze - ale znajdę coś odpowiedniego w VS. Co prawda będzie to ostry hack, ale generalnie afektuje tylko moje testy. Sprawdzę co z tego wyjdzie.

Gdyby ktoś miał jeszcze jakiś inny pomysł jak rozwiązać opisany w pierwszym poście problem - proszę pisać.

Xion - dzięki za radę, ale powalczę jeszcze. Uciekać zawsze mogę ale najpierw chcę nauczyć się jak radzić sobie z takim kodem. Problemy polityczne to już trochę gorsza spawa - pożyjemy zobaczymy, może i z tym da się coś zrobić.

Lerhes
« Ostatnia zmiana: Czerwiec 27, 2015, 22:55:02 wysłana przez Lerhes »

Offline ArekBal

  • Użytkownik

# Czerwiec 27, 2015, 23:29:58
A może wyciągnij globalne do oddzielnego pliku(jak już musisz). Czystsze to niż #ifdefy czy deklarowanie pustych symboli(łatwiej o pomyłki w nazwach).

A "funkcjaLokalnaDlaTegoPliku" możesz nie testować o ile nie zmienia stanu na zewnątrz... a jeśli to robi, to nie jest "lokalnaDlaTegoPliku" i wtedy globale wypadałoby sobie jakoś monitorować przed wejściem, na wyjściu.

Offline Paweł

  • Użytkownik

  • +1
# Czerwiec 28, 2015, 00:32:31
Da się zrobić bez ruszania produkcji, piszę z telefonu:

struct asd123{};
void funkcjaLokalnaWPliku_asd(int zmienna, asd123){
  puts(" kto jest debesciak?");
}

#define funkcjaLokalnaWPliku(...)  funkcjaLokalnaWPliku_asd(__Va_Args__, asd123())

#include "funkcjonalnosc.cpp"

Koniec kodu.


Makro zamieni deklarację na f. Przyjmującą dodatkowo 'asd123 (*)()', ale wywołania będą już z obiektem asd123.

Dziękuję za oklaski ;) (ew. 'cr3ditsy' )
« Ostatnia zmiana: Czerwiec 28, 2015, 00:34:44 wysłana przez Paweł »

Offline Xion

  • Redaktor
    • xion.log

  • +1
# Czerwiec 28, 2015, 01:11:28
Cytuj
IMHO refaktor istniejących systemów(starych i duzych - bo o takich mówimy), prawie zawsze będzie droższy, niż pisanie na nowo(pod warunkiem że nowy projekt tworzą/prowadzą osoby które "już" znają się na rzeczy, nie pchają się w błyskotki).
Przeczytaj to i nie wygłaszaj już takich mądrości.

Offline Lerhes

  • Użytkownik

# Czerwiec 28, 2015, 02:46:17
Paweł - dokładnie o takie coś mi chodziło!

Pod Eclipse działa jak złoto :) Sprawdzę czy będzie mi działało też w VS (w poniedziałek) ale wygląda bardzo obiecująco. Też kombinowałem coś w tym guście:
void void funkcjaLokalnaDlaTegoPliku( int zmiennaLokalna, int dodatkowyParametr ) {}
#define funkcjaLokalnaDlaTegoPliku(a) funkcjaLokalnaDlaTegoPliku(a, 15)
ale niestety takie makro zmieniało też definicję funkcji:
static void funkcjaLokalnaDlaTegoPliku(int zmiennaLokalna )
{
<tutaj znowu 1000 linii kodu>
}
i całość się nie kombinowała.

Brawo, brawo, brawo :)

Tak czułem, że czasami warto poprosić o pomoc, ludzie są jednak mili i pomogą.
Lerhes



Offline Liosan

  • Moderator

# Czerwiec 28, 2015, 09:44:48
Alternatywnym rozwiązaniem byłoby podzielenie kodu C na mniejsze moduły, i potem mockowanie na poziomie linkera - budując testy każdy moduł C byłby biblioteką dynamiczną, i w zależności od potrzeb test wciągałby produkcyjną dllkę albo mockową (i zawsze produkcyjne headery). To wymaga pewnych zmian w kodzie produkcyjnym - ale takich, które sprowadzają się do przesuwania funkcji z jednego miejsca w inne :) Może to byłoby akceptowalne. Wydaje mi się, że to rozwiązanie byłoby bardziej w duchu książki Fowlera, którą wspomniałeś - gdyż testowanie kodu spowoduje że stanie się on bardziej czytelny i utrzymywalny.

Rozwiązanie z testowaniem makrami wydaje mi się odrobinę ciężkie w utrzymaniu - jeśli zmienisz nazwę funkcji funkcjaLokalnaWPliku, makro nadal będzie się "kompilować" ale test nagle zacznie chodzić na produkcyjnej wersji kodu. Posługując się terminologią Fowlera, tracisz możliwość żeby "lean on the compiler" ;)

Liosan

Offline Kos

  • Użytkownik
    • kos.gd

  • +1
# Czerwiec 28, 2015, 15:04:39
A trzeba to robić na poziomie DLL-ek? Wyobrażam sobie że dałoby się też statycznie - bierzesz 1 translation unit, linkujesz go razem z osobnym modułem z mockami, jeszcze innym z samymi testami i tyle.

Wtedy każdy moduł byłby kompilowany, linkowany i testowany osobno. Granularność jest wymuszona i związana z plikami .cpp: nie da się jednocześnie testować jednej funkcji w danym module i mockować drugiej. Ale z drugiej strony kod produkcyjny jest czysty i nie potrzebuje żadnych wstawek - linker robiłby za nas całe podkładanie mocków.

Czy ktoś próbował to robić w ten sposób? (Ja nie próbowałem, odbijam od Was pomysł)

Offline ArekBal

  • Użytkownik

  • +2
# Czerwiec 28, 2015, 19:38:12
Cytuj
Przeczytaj to...
No właśnie... przeczytałeś tamto? A przeczytałeś to co ja tutaj napisałem? Wszystkie artykuły się pokrywają z tym o czym napisałem.
"Software as Spec", "Invention or Implementation?", "The Wish List", "The Big Bang", "Who’s Tending the Store?"
O tych aspektach wspomniałem...

Śmiesznie, bo wygląda jakbyś tylko spojrzał na nagłówek i podlinkował coś dla poparcia własnej tezy nie wdając się w istotę... ciuś, ciuś. :/

BTW. Taki sam artykuł można sklecić w temacie refaktoryzacji która niszczy legacy - działający - software.

Ale niech będzie - wg. życzenia warsztatowego mędrca - nie odzywam się

Offline Liosan

  • Moderator

# Czerwiec 29, 2015, 10:48:48
A trzeba to robić na poziomie DLL-ek? Wyobrażam sobie że dałoby się też statycznie - bierzesz 1 translation unit, linkujesz go razem z osobnym modułem z mockami, jeszcze innym z samymi testami i tyle.
Pewnie, można by tak - ale to wymaga sporo czasu pracy linkera, bo musisz tworzyć oddzielny exec per unit test, i linkować go statycznie. W podejściu z dllkami tworzysz jeden exec do wykonywania wszystkich unit testów (tak działa np. google test), i każdy test case ładuje dllkę w runtime, na podstawie konfiguracji testu a nie danych linkera. Oczywiście Twoje rozwiązanie wymagana mniej modyfikacji produkcyjnego kodu.

Liosan