Test integracyjny a mockowanie named http client

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.

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