To pokaz jakis przykład bo nie bardzo wiem o co chodzi. Obecnie moze np sprawdzic jak zachowuje sie Itemserwis w przypadku roznych odpowiedzi z zewnetrznego zrodla, np jak httpclient zwroci bad request albo poleci na nim jakis exception. Ale chętnie zobacze podejscie twoje.
Proszę bardzo. Załóżmy, że chcemy przeliczyć kwotę po aktualnym kursie NBP.
Najpierw model wyników/błędów:
public class Result<TValue>
{
public Error Error { get; }
public TValue Value { get; }
public bool IsError => this.Error != null;
public Result(Error error)
{
this.Error = error;
}
public Result(TValue data)
{
this.Value = data;
}
public static implicit operator Result<TValue>(Error error) => new Result<TValue>(error);
public static implicit operator Result<TValue>(TValue data) => new Result<TValue>(data);
public override string ToString()
{
return this.IsError ? this.Error.ToString() : this.Value.ToString();
}
}
public class Error
{
public string Message { get; private set; }
public string Details { get; private set; }
public Error(string message, string details = null)
{
this.Message = message;
this.Details = details;
}
public Error(string message, Error inner)
{
this.Message = message;
this.Details = inner.ToString();
}
public override string ToString()
{
return $"{this.Message}\r\n{this.Details}";
}
}
public class ExceptionError : Error
{
public ExceptionError(string message, Exception exception) : base(message, exception.ToString())
{
}
}
public class UnknownCurrencyError : Error
{
public UnknownCurrencyError(string currencyCode) : base($"Nie ma takich piniądzów jak: {currencyCode}")
{
}
}
public abstract class ExternalServiceError : Error
{
public ExternalServiceError(string message, string details) : base(message, details)
{
}
}
public class ExternalServiceAuthenticationError : ExternalServiceError
{
public ExternalServiceAuthenticationError(string details) : base("Ło panie, rzont zabrał nam dostęp do kursów walut!", details)
{
}
}
public class ExternalServiceGenericError : ExternalServiceError
{
public ExternalServiceGenericError(string details) : base("Błąd komunikacji z zewnętrznym API", details)
{
}
}
Logika biznesowa:
public class MoneyConverter
{
private IExchangeRateProvider exchangeRateClient;
public MoneyConverter(IExchangeRateProvider exchangeRateClient)
{
this.exchangeRateClient = exchangeRateClient;
}
public async Task<Result<decimal>> Convert(decimal amount, string currencyCode, DateTime date)
{
var ratio = await this.exchangeRateClient.GetRate(currencyCode, date);
if (ratio.IsError)
{
return ratio.Error switch
{
UnknownCurrencyError uce => new Error("Niepoprawne dane!", uce),
ExternalServiceError ese => new Error("Coś się zepsuło z naszym narodowym bankiem!", ese),
Error err => new Error("W ogóle nie wiadomo o co chodzi.", err),
};
}
return ratio.Value * amount;
}
}
public interface IExchangeRateProvider
{
Task<Result<decimal>> GetRate(string currencyCode, DateTime date);
}
Klient API:
public class NbpApiClient : IExchangeRateProvider
{
// można z factory jeśli jest taka potrzeba, ale lepiej nie, bo potem ktoś wpada na pomysł, żeby mockować
private static HttpClient httpClient = new HttpClient();
public async Task<Result<decimal>> GetRate(string currencyCode, DateTime date)
{
try
{
string requestUri = $"http://api.nbp.pl/api/exchangerates/rates/A/{currencyCode}/{date.ToString("yyyy-MM-dd")}";
var rawResponse = await httpClient.GetAsync(requestUri);
var content = await rawResponse.Content.ReadAsStringAsync();
switch (rawResponse.StatusCode)
{
case HttpStatusCode.OK:
{
var response = JsonConvert.DeserializeObject<NbpResponse>(content);
return (decimal)response.rates.Single().mid;
}
case HttpStatusCode.NotFound:
return new UnknownCurrencyError(currencyCode);
case HttpStatusCode.Unauthorized:
return new ExternalServiceAuthenticationError(content);
default:
return new ExternalServiceGenericError(content);
}
}
catch (Exception ex)
{
return new ExceptionError("Nieznany błąd", ex);
}
}
private class NbpResponse
{
public string table { get; set; }
public string currency { get; set; }
public string code { get; set; }
public Rate[] rates { get; set; }
}
private class Rate
{
public string no { get; set; }
public string effectiveDate { get; set; }
public float mid { get; set; }
}
}
Jak widać można sprawdzić zachowanie serwisu dla różnych wyników zwróconych przez klienta API, wystarczy zamockować IExchangeRateProvider
we wszystkich przypadkach mających sens na tym poziomie abstrakcji: prawidłowy wynik, UnknownCurrencyError
, ExternalServiceError
, Error
.
Serwisu biznesowego żadne HTTP nie powinno interesować, w przeciwnym razie mamy cieknącą abstrakcję i początek spagehtti.
@var Ale finalnie i tak ten IHttpClientFactory (bądź HTTPClient) musi być gdzieś wstrzyknięty i te mocki trzeba w testach porobić. Więc takie podejście sprowadza się głównie do czystrzego kodu ale nie koniecznie dużo zmienia w samym testowaniu (w tym przypadku)
Co daje mockowanie HTTP clienta?
Czy bazę danych (nie ORMa - bazę, wyniki zapytań SQL) też trzeba mockować?
no nie, bo jezeli cos sie zmieni w Twoim DataProvider a Ty go mockujesz podczas testow integracyjnych (e2e) to zmiana nie ma absolutnie zadnego impactu na testy (gdzie np powinny sie wywalic bo serializacja obiektu jest zle zrobiona czy cus)
W testach integracyjnych nie mockujemy "data providera" czy jak ja to nazwałem "API clienta". W testach integracyjnych testujemy integrację z zewnętrznym API. Mamy prawdziwego klienta i prawdziwe API. Jeśli popełnimy błąd w implementacji klienta, to testy integracyjne nam to wykryją. Po to chyba są testy.
Możesz więc sobie zamockować całego DataProvidera (a co za tym idzie wszystko pod spodem - to co jest odpowiedzialne za komunikację HTTP) a możesz zamockować wyłącznie infrastrukturę czy też zewnętrzne api i jako odpowiedzi zewnętrznego serwisu w ramach testów użyć jakiegoś swojego jsona.
A potem mieć niedziałającego providera i zielone testy, bo w końcu w testach mockujemy, a dostawca API coś zmienił.
Co dają takie testy? Moim zdaniem wyłącznie wprowadzają w błąd i szkodzą.
W przypadku testów integracyjnych można (zazwyczaj) sobie zamockować odpowiedź zewnętrznego serwisu
Czyli w testach integracyjnych nie testujemy integracji. To może jednak powinniśmy takie testy nazwać dezintegracyjnymi?
Pytanie zasadnicze - po co testy, które nie działają? Może lepiej od razu ich nie pisać?