Dylemat odnośnie testów jednostkowych w C

0

Witajcie

Piszę większy projekt w C i postanowiłem zabrać się w końcu za testy jednostkowe, żeby zmniejszyć możliwość wystąpienia błędów w zachowaniu kodu. Mam jednak pewien dylemat dlatego proszę Was o pomoc z nim związaną. Mianowicie mam takie dwie funkcje:

FILE *open_config_file(const char *filename_with_ext, size_t filename_len) {
	char *config_file_path = (char *)malloc(CONFIG_PATH_LEN + filename_len);	
	if (config_file_path == NULL) {
		print_error_and_exit("Malloc for config_file_path");
	}


	set_config_file_path(config_file_path, filename_with_ext);

	FILE *config_file = fopen(config_file_path, "r");
	free(config_file_path);
	return config_file;
}

void set_config_file_path(char *config_file_path, const char *filename_with_ext) {
	strncpy(config_file_path, "../conf/", CONFIG_PATH_LEN);
	strcat(config_file_path, filename_with_ext);
}

Chciałbym wykonać test funkcji open_config_file, ale wewnątrz niej wywoływana jest również funkcja set_config_file_path, która nic nie zwraca, ponieważ metody, które są wewnątrz niej wywoływane zwracają jedynie adres źródła, a więc nie obsługuje się błędów. Dlatego nie widzę możliwości, aby stworzyć mock funkcji set_config_file_path. Skoro nie mogę symulować tejże funkcji w teście jednostkowym to przetestowanie zachowania funkcji open_config_file nie będzie testem jednostkowym tylko integralnym, mam rację? Jeśli jest możliwość "mockowania" metody set_config_file_path to jak mogę to zrobić bez zwracania żadnych wartości? Jeśli nie to czy mogę zostawić tę funkcję w oryginale?

Do testowania korzystam z frameworka CMocka, w którym w dość prosty sposób można obsłużyć symulowanie funkcji.

Z góry dziękuję za wszelką pomoc.

1

Żeby prawidłowo testować takie coś potrzebujesz dependency injection.
Jednak problemem nie jest tu set_config_file_path ale wywołanie funkcji fopen.
set_config_file_path nie robi nic skomplikowanego, wiec jej obecność powinieneś olać (to jest szczegół implementacyjny, którego się nie testuje).

Natomiast fopenstanowi problem, bo nie masz kontroli nad zachowaniem tej funkcji. Dlatego to ona powinna być poddana dependency injection.

Nigdy tego nie robiłem dla C, ale może to wyglądać tak:

FILE *open_config_file_inject(const char *filename_with_ext, size_t filename_len, FILE *(*inject_fopen)(const char*, const char*)) {
    char *config_file_path = (char *)malloc(CONFIG_PATH_LEN + filename_len);    
    if (config_file_path == NULL) {
        print_error_and_exit("Malloc for config_file_path");
    }

    set_config_file_path(config_file_path, filename_with_ext);

    FILE *config_file = inject_fopen(config_file_path, "r");
    free(config_file_path);
    return config_file;
}

// wersja produkcyjna:
FILE *open_config_file(const char *filename_with_ext, size_t filename_len, FILE *(*inject_fopen)(const char*, const char*))
{
    return open_config_file_inject(filename_with_ext, filename_len, &fopen);
}

W dobrze skonfigurowanym projekcie (likowanie statyczne i właściwe flagi) kompilator powinien pozbyć się open_config_file_inject w przypadku build release dla kodu produkcyjnego i zachować tą funkcję jedynie dla kodu testowego.

2

Natomiast fopen stanowi problem, bo nie masz kontroli nad zachowaniem tej funkcji. Dlatego to ona powinna być poddana dependency injection.

Hmm w takim razie można to zrobić za pomocą takiej funkcji linkera jak -Wl,--wrap=fopen i mockować właśnie fopen, czyli symulować jej zachowanie w teście. CMocka akurat to umożliwia także dependency injection raczej nie jest potrzebne. Grunt, że set_config_file_path mogę wyeliminować z symulacji :)

0

Mam jeszcze pytanie odnośnie mockowania funkcji fopen. Co wrapper na tę funkcję miałby zwracać? Nie mogę sobie tego wyobrazić jak miałby wyglądać wrapper na funkcję która zwraca przecież adres do otwartego pliku. Wychodzi na to że tak czy inaczej musiałbym wywołać prawdziwą funkcję fopen zatem jaki jest cel pisania tego wrappera?

0

Tworzysz sobie jakąś zmyśloną wartość. To może być cokolwiek rożne od zera.
Ja zwykle robię coś w tym stylu:

static char dummy = 0;
FILE* mockOpenFile(const char*, const char*)
{
    return (FILE*)&dummy;
}

Tyle, że w C++ z gmock, więc to wygląda bardziej elegancko.
Zresztą jakbym miał pisać testy do kodu C to i tak nadal używałbym gtest i gmock. W końcu co komu przeszkadza, że testy dla kodu C są pisane w C++?
Może jak obczaję to https://github.com/mollismerx/elfspy to może podam bardziej życiowy przykład.

0

Wyskrobałem właśnie coś takiego i spełnia swoją rolę:

FILE *__wrap_fopen(const char *path, const char *mode) {
	/* Function checks only one of the parameters because set_config_file_path function
	   changes the value of path and it's hard to check this parameter inside the 
	   test. */
	check_expected_ptr(mode);

	if (!access(path, R_OK)) {
		return mock_type(FILE *);
	} else {
		mock_type(FILE *);
		return NULL;
	}
}

Ten mock jest zastosowany po to, żeby aplikacja się nie wywaliła na błędzie, który ustawia zmienną errno i żeby test mógł być doprowadzony do końca? O to chodzi z tym, że przy testach jednostkowych trzeba mieć kontrolę nad metodami?

2

W dependency injetion chodzi o to, żeby mieć jakąś kontrolę nad zależnościami.
W tym wypadku mówimy o standardowym API C, ale może to być dowolna zewnętrzna biblioteka.

fopen nie jest ciekawym przypadkiem. Ale za to fwrtie otwiera wiele możliwości.
np takie wywołanie size_t count = fwrite(buffer, sizeof(Foo), 14);.
Za pomocą mock-a jesteś w stanie zasymulować scenariusz w którym count będzie miało dowolną wartość od 0 do 14, czego nie jesteś w stanie testować używając prawdziwego pliku.

Dodatkową zaletą, jest to że najczęściej mock-i czynię testy turbo szybkimi. Możesz zasymulować upływ czasu (np godzinę) mimo że minęło 10 ns. Program nie będzie czekał na faktyczne wykonanie operacji IO (np odczyt bazy danych) (pamiętam jak przez 2h próbowałem wytłumaczyć newbie, że test nie misi fizycznie czekać określoną przez logikę ilość czasu).
A im szybciej testy się wykonują tym częściej będziesz je odpalał. W dobrym TDD robi się to dosłownie co 30 sekund. Jeśli testy trwają +5 minut (a pojedynczy test więcej niż 5 sekund), wartość testów jednostkowych spada o rząd wielkości, bo będziesz je uruchamiał 1 - 2 razy dziennie, albo i rzadziej.

0

Jeszcze nie do końca rozumiem sens pisania mock-ów w niektórych przypadkach. Weźmy pod uwagę na przykład funkcję getline(char **lineptr, size_t *n, FILE *stream);. Jedyną wartość jaką mogę tutaj symulować to ilość znaków, które getline pobrało z pliku tekstowego.

  1. Czy jest sens tworzyć tutaj mocka dla getline tylko po to, żeby symulować tę ilość znaków i ustawiać ją dla testów?
  2. Przypuśćmy, że mam napisaną funkcję, która pobiera wartości z pliku konfiguracyjnego, np. PORT: 8000 --> funkcja zwraca 8000. Skoro getline jest funkcją z zewnętrznej biblioteki C to ufam, że jednak działa dobrze i spełnia swoje zadanie. Jeśli spełnia swoje zadanie to po co trudzić się mockowaniem tej metody jeżeli chcę jedynie pobrać linię z pliku?

Żeby implementować te mocki muszę jednak całkiem zrozumieć kiedy je stosować, bo na razie jestem w tym wszystkim lekko zagubiony.

1

Ad.1 możesz symulować cokolwiek. libdl i LD_PRELOAD Twoimi przyjaciółmi. :)
Ad.2 Nie wiem czy Cię dobrze rozumiem: ok, wołasz fukncję biblioteczną, która ma zwrócić tekst z pliku. Ale może chcesz przestować obsługę błędu czy walidację tego konfiga, bo user chce popsuć i wpisał port jako -1 albo "dupa" ;). I wtedy już chyba ma to sens. Pytanie jeszcze jak wołasz tę metodę pobierania tekstu. Bo może faktycznie nie masz potrzeby mockować funkcji bilbioteki standardowej tylko wystarczy podmienić własną, którą ją "owija".

1

wszystko według potrzeb nie ma obowiązku mockowania wszystkiego.
Jeśli maszyneria do mockowania w C nie jest zbyt poręczna, to rozważyłbym użycie fmemopen.
Mock fopen zwracałby FILE* z fmemopen i całej reszty nie trzeba już mockować.
Jeśli uważasz, że mockowanie nie jest potrzebne to nie widzę problemu.
Mocki po prostu otwierają dużo więcej możliwości nieosiągalne w normalnych warunkach.

Kiedyś do biblioteki dodawałem wsparcie dla IWA (Integrated Windows Authentication).
Jako, że kod pisałem na komputerze be dostępu do domeny to zastosowałem dependency injection do wywołań API Windowsa.
Dzięki temu mogłem w ogóle uruchomić testy, na dowolnym komputerze bez względu na jego konfigurację (w domenie albo nie).

1 użytkowników online, w tym zalogowanych: 0, gości: 1