Test integracyjny a mockowanie named http client

0

w Startup.cs mam konfiguracje HttpClient

            services.AddHttpClient("SomeName", c =>
            {
                c.BaseAddress = new Uri("http://localhost:5002/");
            });

Do testowania uzywam _factory = new WebApplicationFactory<Api.Startup>();, bo chce przetestowac caly proces.

Kod ktory wykorzystuje named httpclient

    public class ItemService: IItemService
    {
        private readonly IHttpClientFactory _httpClientFactory;

        public ItemService(IHttpClientFactory httpClientFactory)
        {
            _httpClientFactory = httpClientFactory;
        }
        public async Task<Product> GetItem(int itemId)
        {
            var client = _httpClientFactory.CreateClient("SomeName");
            var response = await client.GetAsync($"items/{itemId}");
            var json = await response.Content.ReadAsStringAsync();
            var item = JsonConvert.DeserializeObject<Item>(json);

            return item;
        }

        public async Task<ItemType> GetItemType(int itemTypeId)
        {
            var client = _httpClientFactory.CreateClient("SomeName");
            var response = await client.GetAsync($"item_types/{itemTypeId}");
            var json = await response.Content.ReadAsStringAsync();
            var productType = JsonConvert.DeserializeObject<ItemType>(json);

            return item;
        }
    }

Moje pytanie brzmi. Jak zmockowac HttpClient("SomeName") bym mogl powiedizec mu na zasadzie

  • Jezeli wywolujesz zapytanie ($"items/{itemId}"); zwroc mi new Item()
  • Jezeli wywolujesz zapytanie ($"item_types/{itemTypeId}") zwroc mi new ItemType()

clienta do testow tworze na zasadzie (choc to jedna w wielu prob)

            var client = _factory.WithWebHostBuilder(builder =>
            {
                builder.ConfigureTestServices(services =>
                {
                    services.AddHttpClient("SomeName"),
                        httpClient => httpClient = new HttpClient(handlerMock.Object));
                });
            }).CreateClient();
0

np tak:

public class FakeHttpMessageHandler :  HttpMessageHandler {
        public virtual HttpResponseMessage Send(HttpRequestMessage request) {
            throw new NotImplementedException("Now we can setup this method with our mocking framework");
        }

        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) {
            return Task.FromResult(Send(request));
        }
    }
Mock<FakeHttpMessageHandler> _fakeHttpMessageHandler = new Mock<FakeHttpMessageHandler> { CallBase = true };
_httpClient = new HttpClient(_fakeHttpMessageHandler.Object);
            _httpClient.BaseAddress=new Uri("https://localhost:5000");
            
            _fakeHttpMessageHandler.Setup(f => f.Send(It.IsAny<HttpRequestMessage>())).Returns((HttpRequestMessage message) => {
                HttpContent content = null;

                return new HttpResponseMessage {
                    StatusCode = statusCode,
                    Content = content
                };
            });

Generalnie to chyba powinieneś zmockować IHttpClientFactory i w testach zwracasz clienta którego utworzyles z fake'owym handlerem.

0

nie bardzo wiem jak to mam uzyc do mojego przypadku

Czy to nie zmockuje wszystko co idzie na localhost:5000? Dwie metody komunikuja sie na localhost:5000 z dwoma roznymi endpointami. Nie wiem jak by mialo to dzialac jezeli chce zwrocic dwa rozne obiekty. Moglbys pokazac na przykladzie?

Dla przykladu zalozmy ze controller wyglada tak

        [HttpPost]
        [Route("api/item")]
        public async Task<string> Calculate([FromBody] string itemId)
        {
            var item =  await _itemService.Get(itemId);
            var itemType = await _itemService.GetType(item.ItemTypeId);

           // (...) cos tam dalej jest robione na podstawie item i itemType

        }

chcialbym zeby item byl

            var item= new Item
            {
                Id = 1,
                Name =  "Item",
                ProductTypeId =  1
            };

a zeby itemType byl

            var itemType= new ItemType
            {
                Id =  1,
                Name =  "Item type",
            };
0

Nie dokońca zrozumiałem zanim dałem odpowiedz. Ale ja tak mocki rejestruje

 public abstract class BaseClassTests {
        protected readonly WebApplicationFactory<Startup> _factory;

        protected Dictionary<Type, object> Mocks = new Dictionary<Type, object>();
        protected BaseClassTests(WebApplicationFactory<Startup> factory) {
            _factory = factory;
        }

        public HttpClient CreateNewClient()
        {

            var client = _factory.WithWebHostBuilder(builder =>
            {
                builder.UseEnvironment("IntegrationTesting");                
                
                builder.ConfigureTestServices(services =>
                {
                     
                    foreach (KeyValuePair<Type, object> entry in Mocks) {
                        services.AddScoped(entry.Key, x => entry.Value);
                    }
                    services.AddAuthentication(options => {
                        options.DefaultAuthenticateScheme = TestAuthHandler.DefaultScheme;
                        options.DefaultScheme = TestAuthHandler.DefaultScheme;
                    }).AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.DefaultScheme, options => { });
                });
Mocks.Add(typeof(IhttpClientFacotry), _tuInstancjaMocka); // to jest ten dicitonary z BaseClassTests.

Bo generalnie chcesz przetestować service czy uderza HttpClient dobrze? To ten sposób powinien zadziałać ale nie testowałem tego tak. Bardziej jednostkowo serwis wykorzystujący HTTP clienta.

0

Bo generalnie chcesz przetestować service czy uderza HttpClient dobrze?

nie
Chce przetestowac endpoint i zmockowac jakiekolwiek "trzecie" zaleznosci.
Na zasadzie czarnej skrzynki. Tak jakbym to testowal za pomoca postmana. Wysylam zapytanie do serwera o id 1 i chce by mi zwrocil na zasadzie "fajny masz numer 1 i typ 1"

Tutaj akuratnie ta zaleznoscia jest httpclient ktory pyta o dane jakis inny serwis

0

To pozbądź się tego

services.AddHttpClient(NamedHttpClientConstants.ProductData,
                        httpClient => httpClient = new HttpClient(handlerMock.Object));
              

A dodaj IHttpClientFactory zmockowany według mojego sposobu. I wtedy mock decyduje co zwrócić do serwisu.

0

No nie mam jak Ci teraz zrobić konkretnego przykładu:

var client = _factory.WithWebHostBuilder(builder =>
            {
                builder.ConfigureTestServices(services =>
                {
                    services.AddHttpClient(NamedHttpClientConstants.ProductData,
                        httpClient => httpClient = new HttpClient(handlerMock.Object));
                });
            }).CreateClient();

Ale ten kod tworzy Ci httpClienta + "server testowy" do testowania integracyjnego twojej aplikacji. (żebyś nie musial postmana odpalać co chwila powiedzmy).

Ale odpalanie tego skutkuje wywołaniem servicu który za pomocą HttpClientFactory tworzy httpClienta (po nazwie: SomeName) i ten klienta z zewnętrznego serwisyu zwraca Ci dane. I wiadomo, że w testach nie uderzasz do prawdziwego serwisu tylko musisz mieć mocka. No to cały myk polega żebyś zmockowa IHttpClientFactory, żeby Ci zwrócił takiego klienta jaki Ci pasuje (po nazwie) z jakimś fakeowym base adresem. A tego HttpClienta który zwróci IHttpClientaFactory tworzysz w ten sposób z mojego pierwszego posta.
No jeśli nie o to chodzi to już nie wiem :P

0

Ale odpalanie tego skutkuje wywołaniem servicu który za pomocą HttpClientFactory tworzy httpClienta (po nazwie: SomeName) i ten klienta z zewnętrznego serwisyu zwraca Ci dane.

tak

I wiadomo, że w testach nie uderzasz do prawdziwego serwisu tylko musisz mieć mocka.

zgadza sie

No to cały myk polega żebyś zmockowa IHttpClientFactory, żeby Ci zwrócił takiego klienta jaki Ci pasuje (po nazwie) z jakimś fakeowym base adresem.

nie rozumiem co daje mi fakeowy base address. Nie mam zadnego dzialajacego base address

Dlatego chcialbym, zeby

var response = await client.GetAsync($"items/{itemId}");

nie wykonywal zadnego zapytania, przechwycic zapytanie i po prostu zwrocic jakis stworzony przeze mnie wczesniej content/obiekt

na zasadzie

var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler.Protected()
    .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
    .ReturnsAsync(new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content = new StringContent("{'name':thecodebuzz,'city':'USA'}"),
    });

Ale to przechwytuje wszystkie zapytania. Skad mam wiedziec czy byl uzyty endpoint item czy itemType?

A tego HttpClienta który zwróci IHttpClientaFactory tworzysz w ten sposób z mojego pierwszego posta.

nadal, httpclient bedzie mial w parametrze dwa rozne endpointy. Jak z dwoch roznych endpointow zwrocic dwa rozne obiekty? Nie wiem jak to osiagnac. Niestety

Podam caly test

        [Fact]
        public async Task SampleTest()
        {
            var item = new Item
            {
                Id = 1,
                Name =  "Test Item",
                ItemTypeId =  1
            };

            var itemType = new ItemType
            {
                Id =  1,
                Name =  "Test type",
            };
            
            var client = _factory.WithWebHostBuilder(builder =>
            {
            }).CreateClient();

            var request = 1;

            var httpContent = new StringContent(JsonConvert.SerializeObject(request));
            httpContent.Headers.Remove("Content-Type");
            httpContent.Headers.Add("Content-Type", "application/json");
            //Act
            var response = await client.PostAsync("api/item", httpContent);

            //Assert
            var json = await response.Content.ReadAsStringAsync();
            var result = JsonConvert.DeserializeObject<Response>(json);
            Assert.Equal(
                expected: $"yey you have {item.Name} with {itemType.Name}",
                actual: result.something
            );
        }

Chce by item byl zwracany z var response = await client.GetAsync($"items/{itemId}"); a itemType byl zwracany z var response = await client.GetAsync($"item_types/{itemTypeId}");.
Nie rozumiem jak zmockowanie httpclient (a dokladniej HttpMessageHandler) moze odroznic na jaki endpoint jest wykonywane zapytanie

teraz gdy uruchomie ten test. to oczywiscie na lini await client.GetAsync($"items/{itemId}"); wyrzuci exceptiona ze nie znalazlo hosta.
Potrzebowalbym przykladu na podstawie tego co wrzucilem, sadze ze pomoze mi to lepiej zrozumiec istote rozwiazania

0

A czy ty przypadkiem nie chcesz zrobić jakiegoś FakeStartup i tą 3rd party zależność podstawić w testach jako swoją fakeową implementacje?

A zatem testy stawiając aplikacje używałyby FakeStartup, który miałby np. services.AddTransient<IExternalAPI, FakeExternalAPI>();, a normalny startup services.AddTransient<IExternalAPI, RealExternalAPI>();

to chyba tu

Customize WebApplicationFactory

1

przykład:

public interface Miedzymordzie
{
	int GetInt();
}

public class RealMiedzyMordzie : Miedzymordzie
{
	public int GetInt()
	{
		return 10;
	}
}
public class FakeMiedzyMordzie : Miedzymordzie
{
	public int GetInt()
	{
		return 100;
	}
}

Controller:

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
	private readonly ILogger<WeatherForecastController> _logger;

	public Miedzymordzie _Impl { get; }

	public WeatherForecastController(ILogger<WeatherForecastController> logger, Miedzymordzie test)
	{
		_Impl = test;
	}

	[HttpGet("xD")]
	public IActionResult Test()
	{
		return Ok(_Impl.GetInt());
	}
}

Prawdziwy Startup:

public class Startup
{
	public Startup(IConfiguration configuration)
	{
		Configuration = configuration;
	}

	public IConfiguration Configuration { get; }

	// This method gets called by the runtime. Use this method to add services to the container.
	public void ConfigureServices(IServiceCollection services)
	{
		services.AddScoped<Miedzymordzie, RealMiedzyMordzie>();
		services.AddControllers();
		services.AddSwaggerGen(c =>
		{
			c.SwaggerDoc("v1", new OpenApiInfo { Title = "web", Version = "v1" });
		});
	}

	// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
	public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
	{
		if (env.IsDevelopment())
		{
			app.UseDeveloperExceptionPage();
			app.UseSwagger();
			app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "web v1"));
		}

		app.UseHttpsRedirection();

		app.UseRouting();

		app.UseAuthorization();

		app.UseEndpoints(endpoints =>
		{
			endpoints.MapControllers();
		});
	}
}

Fake Startup:

public class WebAppFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var descriptor = services.SingleOrDefault(d => d.ServiceType ==typeof(RealMiedzyMordzie));

            services.Remove(descriptor);

            services.AddScoped<Miedzymordzie, FakeMiedzyMordzie>();
        });
    }
}

Test:

public class UnitTest1 : IClassFixture<WebAppFactory<Startup>>
{
	private readonly WebAppFactory<Startup> _factory;

	public UnitTest1(WebAppFactory<Startup> factory)
	{
		_factory = factory;
	}

	[Fact]
	public async void Test1()
	{
		// Arrange
		var client = _factory.CreateClient();

		// Act
		var response = await client.GetAsync("/WeatherForecast/xD");

		// Assert
		response.EnsureSuccessStatusCode(); // Status Code 200-299
		Assert.Equal("100", await response.Content.ReadAsStringAsync());
	}
}

HTTP do prawdziwej:

curl https://localhost:5001/WeatherForecast/xD
StatusCode        : 200
StatusDescription : OK
Content           : 10

HTTP do Fakeowej:

screenshot-20210610184633.png

0

@WeiXiao

dzieki za przyklad :)

A czy ty przypadkiem nie chcesz zrobić jakiegoś FakeStartup i tą 3rd party zależność podstawić w testach jako swoją fakeową implementacje?

nie.

Jezeli chcialbym miec jakas fake'owa implementacje klasy ktora zarzadza cala komunikacja z 3rd part library moglbym ja po prostu zmockowac (tak mi sie wydaje, ze rezultat w przypadku pisania fakeowej implementacji a mockiem bedzie taki sam)

            var mockService= new Mock<IItemService>();
            mockService.Setup(x => x.GetItem(It.IsAny<int>()))
                .ReturnsAsync(product);
            
            mockService.Setup(x => x.GetItemType(It.IsAny<int>()))
                .ReturnsAsync(productType);

            var client = _factory.WithWebHostBuilder(builder =>
            {
                builder.ConfigureTestServices(services =>
                {
                    services.AddTransient<IItemService>(sp => mockService.Object);
                });
            }).CreateClient();

i nie musialbym utrzymywac fake'owej zaleznosci :)

W sumie ciekaw jestem do czego przydaje sie pisanie fake'owej zaleznosci?

Chce przetestowac "produkcyjny" kod taki jak jest. By moc go przetestowac (zeby sie uruchamial i nie crashowal) musze sprawic by ta linijka kodu

 await client.GetAsync($"products/{productId}");

nie wyrzucala "nieznany host" i rzucala exceptionem. Tylko zwrocila jakis obiekt ktory sie spodziewam od zewnetrznego serwisu.
** Zakladam ** ze zewnetrzne serwisy dzialaja dobrze i wiem co ma zwrocic na podstawie danych ktore wysylam.
Po prostu chce przetestowac "swoj" kod bez zadnych zewnetrznych zaleznosci nad ktorymi nie mam kontroli. W tym konkretnym przypadku jest to httpclient ktory komunikuje sie z innym serwisem/projektem i nie wiem jak rozwiazac ten konkretny przypadek

1

muszę przyznać, że trochę się gubię, ale

przerabiając minimalnie mój w/w przykład - a tak?

zamiast mojego 100 wstaw sobie jsona i odpowiedni url pattern

public class WebAppFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
	protected override void ConfigureWebHost(IWebHostBuilder builder)
	{
		builder.Configure(c =>
		{
			c.UseRouting();
			c.UseEndpoints(endpoints =>
			{
				endpoints.MapGet("/WeatherForecast/xD", async context =>
				{
					await context.Response.WriteAsync("100");
				});
			});
		});
	}
}

oczywiście - u mnie Test1 nadal działa :)

0

Tak to zadziala, nawet z czegos takiego wyszedlem na poczatku
tylko jak teraz zrobic by dla kazdego innego testu wartosci zwracane byly inne?

np chcesz napisac Test2 ktory sprawdza czy wartosc jest 1000. Test3 ktory sprawdza czy wartosc jest 10000 itd.

Mam logike ktora bazuje na wartosci zwracanej z tego serwisu, fajnie by bylo gdybym mogl podac kilka roznych wartosci a nie ciagle miec ten sam w tescie.

W sumie nie wpadlem na to (dopiero teraz o tym pomyslalem) ze teoretycznie moglbym tworzyc klase WebAppFactory per use-case ale wtedy bede mial takich klas dziesiatki co raczej nie jest optymalnym rozwiazaniem

1

Nie twierdzę że jest to dobry pomysł, ale

public class WebAppFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
    private Dictionary<string, object> KtoNieHakujeTenNieProgramujeOrSomethingLikeThat = new()
    {
        { "/WeatherForecast/xD1", 100 },
        { "/WeatherForecast/xD2", new { Name = "XD" } },
    };

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.Configure(c =>
        {
            c.UseRouting();
            c.UseEndpoints(endpoints =>
            {
                foreach (var item in KtoNieHakujeTenNieProgramujeOrSomethingLikeThat)
                {
                    endpoints.MapGet(item.Key, async context =>
                    {
                        await context.Response.WriteAsync(JsonConvert.SerializeObject(item.Value));
                    });
                }
            });
        });
    }
}
[Fact]
public async void Test1()
{
    // Arrange

    var client = _factory.CreateClient();

    // Act
    var response = await client.GetAsync("/WeatherForecast/xD1");

    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299
    var output = await response.Content.ReadAsStringAsync();
    Assert.Equal("100", output);
}

[Fact]
public async void Test2()
{
    // Arrange

    var client = _factory.CreateClient();

    // Act
    var response = await client.GetAsync("/WeatherForecast/xD2");

    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299

    var output = await response.Content.ReadAsStringAsync();
    var obj = JsonConvert.DeserializeObject<dynamic>(output);

    Assert.Equal("XD", obj.Name.ToString());
}
1

Jest jeszcze jedna opcja, że zamiast używać tego WebApplicationFactory, to zrobisz sobie jakąś fajną metodke do stawiania servera typu CreateServer(endpoint, json) która generuje port (np. inkrementowalny zamiast losowy) i go zwraca

[Fact]
public void Test1()
{
    var webhostBuilder = new WebHostBuilder()
    .UseKestrel()
    .UseUrls("http://localhost:12332/")
    .ConfigureServices(x => 
    {
        x.AddControllers();
    })
    .Configure(c =>
    {
        c.UseRouting();
        c.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("asd", async context =>
            {
                await context.Response.WriteAsync("100");
            });
        });
    });

    webhostBuilder
        .Build()
        .RunAsync();

    var restClient = new RestClient("http://localhost:12332");

    var request = new RestRequest("asd", Method.GET);
    var response = restClient.Execute(request);

    Assert.Equal("100", response.Content);
}
0

brzydkie to ale dziala!

        [Fact]
        public async Task SampleTest()
        {
            var item = new Item
            {
                Id = 1,
                Name =  "Test Item",
                ItemTypeId =  1
            };

            var itemType = new ItemType
            {
                Id =  1,
                Name =  "Test type",
            };

           var webhostBuilder = new WebHostBuilder()
                .UseKestrel()
                .UseUrls("http://localhost:5002/")
                .ConfigureServices(x => 
                {
                    x.AddControllers();
                })
                .Configure(c =>
                {
                    c.UseRouting();
                    c.UseEndpoints(endpoints =>
                    {
                        endpoints.MapGet("items/{id:int}",
                            context =>
                            {
                                return context.Response.WriteAsync(JsonConvert.SerializeObject(item));
                            });
                        endpoints.MapGet("item_types/{id:int}",
                            context =>
                            {
                                return context.Response.WriteAsync(JsonConvert.SerializeObject(itemType ));
                            });
                    });
                });

            webhostBuilder
                .Build()
                .RunAsync();

            var client = _factory.WithWebHostBuilder(builder =>
            {
            }).CreateClient();

            var request = 1;

            var httpContent = new StringContent(JsonConvert.SerializeObject(request));
            httpContent.Headers.Remove("Content-Type");
            httpContent.Headers.Add("Content-Type", "application/json");
            //Act
            var response = await client.PostAsync("api/item", httpContent);

            //Assert
            var json = await response.Content.ReadAsStringAsync();
            var result = JsonConvert.DeserializeObject<Response>(json);
            Assert.Equal(
                expected: $"yey you have {item.Name} with {itemType.Name}",
                actual: result.something
            );
        }
1

Więc ja robiłem tak:
Potrzebowałem testować serwis który uderzał do LinkedIn httpclientem pod różne adresy.
Robiłem httpClient z fejkowym Messagehandlerem. I w tym message handlerze zawierałem logikę (sprawdzałem jaki url jest wołany)

 [Fact]
        public async Task Call_GetUserProfile_Should_Return_UserData()
        {
            HttpClient _httpClient;
            Mock<FakeHttpMessageHandler> _fakeHttpMessageHandler = new Mock<FakeHttpMessageHandler> { CallBase = true };
            _httpClient = new HttpClient(_fakeHttpMessageHandler.Object);

            var optionsMock = new Mock<IOptions<LinkedInSettings>>();
            optionsMock.Setup(d => d.Value).Returns(() => new LinkedInSettings
            {
                AccessTokenUri = "http://someLinkedInAccessTokenUri.com/",
                ClientId = "Id",
                ClientSecret = "Secret",
                ProfileApi = "http://someLinkedInProfileApiUri.com/",
                RedirectUri = "http://someLinkedInRedirectUru.com/",
                UserEmailApi = "http://someLinkedInEmailApiUri.com/"
            });

            _fakeHttpMessageHandler.Setup(f => f.Send(It.IsAny<HttpRequestMessage>())).Returns((HttpRequestMessage message) =>
            {
                HttpContent content = null;

                if (string.Equals(message.RequestUri.AbsoluteUri, optionsMock.Object.Value.AccessTokenUri, StringComparison.OrdinalIgnoreCase))
                    content = new StringContent(System.IO.File.ReadAllText("LinkedInAuth.json"));

                if (string.Equals(message.RequestUri.AbsoluteUri, optionsMock.Object.Value.ProfileApi, StringComparison.OrdinalIgnoreCase))
                    content = new StringContent(System.IO.File.ReadAllText("LinkedInProfile.json"));

                if (string.Equals(message.RequestUri.AbsoluteUri, optionsMock.Object.Value.UserEmailApi, StringComparison.CurrentCultureIgnoreCase))
                    content = new StringContent(System.IO.File.ReadAllText("LinkedInEmail.json"));

                if (message.RequestUri.AbsoluteUri.Contains("image"))
                    content = new StringContent("someImageBytes");

                return new HttpResponseMessage
                {
                    StatusCode = HttpStatusCode.OK,
                    Content = content
                };
            });

            LinkedInService linkedIndService = new LinkedInService(_httpClient, optionsMock.Object);
            var userData = await linkedIndService.GetUserProfileInfo("someCode");
            Assert.Equal("[email protected]", userData.Email);
            Assert.Equal("TestFirstName", userData.FirstName);
            Assert.Equal("TestLastName", userData.LastName);
            Assert.NotEmpty(userData.Image);
        }

Ty możesz np zrobić coś takiego

[Theory]
        [InlineData("linkedin", "SomeCode12334", "somename", "invalid_client", HttpStatusCode.Unauthorized)]
        [InlineData("linkedindd", "SomeCode12334", "somename", "unsupported_grant_type", HttpStatusCode.BadRequest)]
public async Task Call_LoginViaLinkedIn_Should_Return_OpenIdictError(string grant_type, string code, string client, string responseError, HttpStatusCode statusCode)

Gdzie parametry metody testującej to są np nazwy plików Jsonowych (w solucji testowej) zawierające odpowiedni content. I w zależności od test casu wczytujesz odpowiedni plik.

2
Mock<IHttpClientFactory> fakefactory = new Mock<IHttpClientFactory>();
            
            var someNamedClient = FakeHttpClient.Get();
            fakefactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns((string clientName) => {
                return someNamedClient;
            });
            

            var httpClient = base.CreateNewClient(new Dictionary<System.Type, object>
            {                
                { typeof(IHttpClientFactory),  fakefactory.Object}
            });

Gdzie FakeHttpClient.Get() zwraca HttpClienta z zamokowanym httpmessageHandlerem (czyli kawałek kodu z postów wyżej)

I w ten sposób wstrzykujesz fakowy IHttpClientFactory który po wywołaniu CreateClient("SomeName") zwróci Ci httpClienta z fakowym message handlerem. I w tym message handlerze sprawdzasz co jest wysyłane (na jaki url) i zwracasz co potrzebujesz.

2

No, a jakie są zalety takiego podejścia nad napisaniem sobie klasy odpowiedzialnej za komunikację z zewnętrznym serwisem wraz z interfejsem do niej, który będzie można dowolnie i banalnie mockować?

1
somekind napisał(a):

No, a jakie są zalety takiego podejścia nad napisaniem sobie klasy odpowiedzialnej za komunikację z zewnętrznym serwisem wraz z interfejsem do niej, który będzie można dowolnie i banalnie mockować?

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.

2

Ja robię to zazwyczaj w sposób, który jest o wiele łatwiejszy w użyciu (czy to od strony aplikacji czy testów), chyba podobnie do tego co proponuje @somekind.
Zamiast wrzucać HttpClienta gdzie tylko się da tworzę sobie klasę, która wystawia metody służące do komunikacji z danym serwisem.

Dzięki temu zamiast używać gdzieś w warstwie aplikacji HttpFactory i HttpClienta które mają mi coś zwrócić lub wysłać używam abstrakcji np. ICustomerDataProvider i wywołuję sobie metodę Get tego właśnie interfejsu.

W samym kodzie robi się czyściej bo deleguję odpowiedzialność za pobranie i deserializację danych do osobnego komponentu a w testach mockowanie tego jest banalne bo wystarczy zamockować ICustomerDataProvider i ustawić sobie co trzeba.

Finalnie dostaję:

  1. klasa która ma wykonać jakieś operację na profilu użytkownika - pełna abstrakcja nad tym skąd i w jaki sposób pobierane są dane
  2. klasa implementująca ICustomerDataProvider - tutaj trzymam tylko logikę odpowiedzialną za wywołanie akcji pobierającej dane oraz przetworzenie pobranych danych w taki sposób, żebym mógł je potem wygodnie wykorzystać w klasie z pkt 1
  3. klasa, która udostępnia komunikację po HTTP - w niej umieszczam logikę odpowiedzialną za całą infrastrukturę powiązaną z http, czyli konfiguracją klienta, ustawianiem nagłówków, sprawdzaniem statusu itd (staram się reużywać ten komponent gdzie tylko się da, jeśli w ramach aplikacji korzystamy np z jednego api to zazwyczaj da się użyć tej klasy w kontekscie całej komunikacji. Jeżeli mamy kilka źródeł danych czy serwisów to konieczność ustawiania innych nagłówków czy autoryzacji może wymagać napisania odrębnych providerów)

Każda z tych klas może być przetestowana jednostkowo w całkowitej izolacji od niższych warstw, wszystkie razem mogą być testowane integracyjne aby sprawdzić zachowanie w określonych warunkach.

1

@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)

0

@var:

Każda z tych klas może być przetestowana jednostkowo w całkowitej izolacji od niższych warstw,

zgadza sie, dlatego tez mam "DataConnector` (u Ciebie nazwany DataProvider)

wszystkie razem mogą być testowane integracyjne aby sprawdzić zachowanie w określonych warunkach.

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)

1
szydlak napisał(a):

@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)

Oczywiście, ale dzięki temu masz wybór na którym poziomie chcesz mockowować, albo nawet możesz nie mockować i wykonywać normalne odpytanie HTTP.
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.

fasadin napisał(a):

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)

Jak wyżej, jeżeli mockujesz tego DataProvidera to oczywiście, zmiana może zostać niewykryta, testy które powinny się wywalić przejdą. Tyle, że to już jest coś co zahacza o samą metodykę pisania testów bo jeśli chcemy testować integracyjnie jakiś moduł to chyba oczywiste powinno być to że nie mockujemy składowych tego modułu bo przez to nie sprawdzimy ich działania.

W przypadku testów integracyjnych można (zazwyczaj) sobie zamockować odpowiedź zewnętrznego serwisu i w przypadku struktury jaką tutaj zaproponowałem sprowadzałoby się to do tego, że ustawiałbym mocka odpowiedzi HttpClienta na dane zapytanie a resztę procesowania odpalał bez żadnych mocków (nie licząc zewnętrznych zależności od których chciałbym się izolować w jakiś sposób).
W ten sposób cały use case od wywołania kontrolera aż po zwrot przez niego odpowiedzi może zostać w pełni przetestowany mając tyle wygody, że nie jesteśmy na etapie testu zależni od zewnętrznego api.
I dzięki temu możemy sobie przygotować pełen zestaw testów odpowiednio preparując zestaw jsonów które będą użyte jako dane dla testów sprawdzających działanie w warunkach kiedy zewn. serwis zwraca poprawne, niepoprawne albo wcale nie zwraca danych.

0

sobie zamockować odpowiedź zewnętrznego serwisu i w przypadku struktury jaką tutaj zaproponowałem sprowadzałoby się to do tego, że ustawiałbym mocka odpowiedzi HttpClienta na dane zapytanie a resztę procesowania odpalał bez żadnych mocków

caly czas o to mi chodzi :) dlatego szukalem sposobu by zmockowac Httpclient (a raczej httpmessagehandler). Jeszcze nie probowalem sposobu @szydlak ale moim zdaniem jest to najlepszy sposob na osiagniecie celu ktory chce uzyskac.

Moje rozwiazanie (postawianie serwera testowe na danym endpoincie i zwracanie jakis obiektow) jest.... bledogenne i nie za bardzo mozliwe by testowac asyncrhonicznie :)

Dlatego jak znajde chwile przepisze ten test na rozwiazanie od @szydlak :)

5
szydlak napisał(a):

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.

szydlak napisał(a):

@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ć?

fasadin napisał(a):

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.

var napisał(a):

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ć?

0

jak chcesz mieć dobry test to zamiast bawić się w mockowanie możesz użyć WireMock.NET z nugeta. Dzięki niemu masz "black box test" i możesz sobie dowolnie sterować odpowiedzią "serwera". Tu masz przykład: https://github.com/WireMock-Net/WireMock.Net/wiki/Using-WireMock-in-UnitTests

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