Czytam sobie wątki:
I artykuły:
Jak rozumiem, wielu sądzi, że testowanie poszczególnych klas czy metod bardzo często kompletnie nie ma sensu. Zamiast tego należy pisać testy możliwie jak najbardziej integracyjne, tak bardzo integracyjne, jak to tylko praktycznie możliwe.
A zatem: API Restowe najchętniej testowac należy w ten sposób, że się wysyła zapytania i sprawdza odpowiedzi. Webapkę natomiast za pomocą jakiegoś Selenium albo coś. To przynajmniej jest ideał, od którego trzeba będzie odstępować niekiedy schodząc czasem nawet do poziomu testowania poszczególnych metod, ale zaczynamy od e2e i schodzimy niżej jeśli musimy, a nie na odwrót.
Ma to dla mnie sens.
Przemyślenia mnie jednak naszły, jak to się ma do wielu "dobrych praktyk" powszczechnie gloszonych?
Oddzielanie logiki od interfejsu
Wielu promuje, by niezwykle rygorystycznie oddzielać logikę od interfejsu. Oznacza to na przykład, że nie ma się prawa wstrzyknąć bazy danych do kontrolera w RESTowym API: zadania kontrolera ograniczać się mają do obsługi spraw związanych z HTTP oraz przekazania odpowiedzialności za chociażby pobranie z bazy danych wierszy, które trzeba zwrócić w odpowiedzi do jakiś serwisów albo innych komend CQRS.
Celem takiego postępowania, jak rozumiem, jest:
- Łatwość pisania unit testów,
- Łatwość tworzenia wielu interfejsów do jednej aplikacji.
Jednak jeśli testujemy, o ile to możliwe, integracyjnie, to kwestia unit testów nam spada całkowicie. Co do wielu interfejsów, to najczęściej YAGNI - w 99% przypadków interfejs będzie zawsze i tylko jeden.
Inversion of Control oraz Dependency Injection
Celem wstrzykiwania zależności do klas (zamiast walenia new
albo static
) jest, jak rozumiem:
- Łatwość pisania unit testów,
- Łatwość wyboru implementacji jakiejś zależności w runtime.
Przy czym są dwa możliwe użycia wstrzykiwania zależności. Można wstrzykiwać samą implemetację, albo fabrykę. W tym pierwszym przypadku nie uzyskujemy nic, jeśli chodzi o wybór implementacji w runtime, to nam służy tylko do unit testów.
A zatem
Czy dobrze rozumuję, że jeśli odchodzimy od unit testów do testów możliwie integracyjnych, to oddzielanie logiki od interfejsu oraz wstrzykiwanie zależności powoli przestaje mieć sens?
Tam, gdzie istotnie będziemy mieć wiele UI, tam musimy dalej pilnować rygorystycznego podziału. Jeśli mamy tylko jeden UI, ale w jakiejś części kodu dla jakiejkolwiek przyczyny musimy zejść na poziom unit testów, tam dalej musimy oddzielić tę logikę od interfejsu. Ale w pozostałych przypadkach możemy chyba mieć logikę w kontrolerze?
Co do wstrzykiwania zależności: Znowu to samo, tam gdzie musimy zejść na poziom unit testów, tam musimy wstrzyknąć w jakąś klasę zależności, które muszą być zfakowane podczas testów. Ale w pozostałych przypadkach wstrzykiwanie zależności traci swój sens i cel, a tylko dodaje cognitive overhead?
Ciekawe jest podejście z artykułu https://tyrrrz.me/blog/unit-testing-is-overrated, który zalinkowałem wyżej, a który - z mojego doświadczenia - bywa niekiedy rzucany w internetach, by poprzeć opinię, że należy testować przede wszystkim integracyjnie.
Autor tam najpierw prezentuje kod, wg niego "poprawny z punktu widzenia OOP", ale który jest trudny do unit testowania: (ten kod nie używa DI!)
public class LocationProvider : IDisposable
{
private readonly HttpClient _httpClient = new HttpClient();
// Gets location by query
public async Task<Location> GetLocationAsync(string locationQuery) { /* ... */ }
// Gets current location by IP
public async Task<Location> GetLocationAsync() { /* ... */ }
public void Dispose() => _httpClient.Dispose();
}
public class SolarCalculator : IDisposable
{
private readonly LocationProvider _locationProvider = new LocationProvider();
// Gets solar times for current location and specified date
public async Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date) { /* ... */ }
public void Dispose() => _locationProvider.Dispose();
}
Następnie autor refaktoruje ten kod, by można było go unit testować (już mamy DI!)
public interface ILocationProvider
{
Task<Location> GetLocationAsync(string locationQuery);
Task<Location> GetLocationAsync();
}
public class LocationProvider : ILocationProvider
{
private readonly HttpClient _httpClient;
public LocationProvider(HttpClient httpClient) =>
_httpClient = httpClient;
public async Task<Location> GetLocationAsync(string locationQuery) { /* ... */ }
public async Task<Location> GetLocationAsync() { /* ... */ }
}
public interface ISolarCalculator
{
Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date);
}
public class SolarCalculator : ISolarCalculator
{
private readonly ILocationProvider _locationProvider;
public SolarCalculator(ILocationProvider locationProvider) =>
_locationProvider = locationProvider;
public async Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date) { /* ... */ }
}
Autor narzeka, że ten "poprawiony" kod jest dużo bardziej skomplikowany niż oryginalny i że to daje sporo cognitive overhead, a wszystko tylko po to, by mogły być unit testy.
Zatem autor dalej pokazuje, jak wyrzucić większość unit testów i testować integracyjnie. W finalnej wersji kodu autor pozwala sobie na herezję, czyli logikę w kontrolerze, jednak dalej stosuje DI:
[ApiController]
[Route("solartimes")]
public class SolarTimeController : ControllerBase
{
private readonly SolarCalculator _solarCalculator;
private readonly LocationProvider _locationProvider;
private readonly CachingLayer _cachingLayer;
public SolarTimeController(
SolarCalculator solarCalculator,
LocationProvider locationProvider,
CachingLayer cachingLayer)
{
_solarCalculator = solarCalculator;
_locationProvider = locationProvider;
_cachingLayer = cachingLayer;
}
[HttpGet("by_ip")]
public async Task<IActionResult> GetByIp(DateTimeOffset? date)
{
var ip = HttpContext.Connection.RemoteIpAddress;
var cacheKey = ip.ToString();
var cachedSolarTimes = await _cachingLayer.TryGetAsync<SolarTimes>(cacheKey);
if (cachedSolarTimes != null)
return Ok(cachedSolarTimes);
// Composition instead of dependency injection
var location = await _locationProvider.GetLocationAsync(ip);
var solarTimes = _solarCalculator.GetSolarTimes(location, date ?? DateTimeOffset.Now);
await _cachingLayer.SetAsync(cacheKey, solarTimes);
return Ok(solarTimes);
}
/* ... */
}