Cuando trabajás con aplicaciones .NET que consumen APIs externas, tarde o temprano te encontrás con este dilema: ¿cómo escribís unit tests que no dependan de que esa API esté disponible? La respuesta está en combinar HttpClientFactory con Moq para simular las respuestas HTTP de forma completamente aislada.
En este artículo vamos a ver por qué no podés simplemente hacer new Mock<IHttpClientFactory>() y salir andando, y cómo resolver el problema de forma elegante.
¿Por qué HttpClientFactory y no HttpClient directo?
Antes de entrar al testing, un poco de contexto. Instanciar HttpClient directamente con new HttpClient() en cada request es una práctica peligrosa: puede causar socket exhaustion porque el sistema operativo no libera los sockets rápidamente. IHttpClientFactory resuelve este problema manejando el pool de conexiones por vos.
El patrón típico se ve así:
public class MyExternalService
{
private readonly IHttpClientFactory _httpClientFactory;
public MyExternalService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task DeleteObject(string objectName)
{
string path = $"/objects?name={objectName}";
var client = _httpClientFactory.CreateClient("ext_service");
var httpResponse = await client.DeleteAsync(path);
httpResponse.EnsureSuccessStatusCode();
}
}
El problema: ¿cómo testear esto sin hacer una llamada HTTP real?
El problema con el mocking directo
El instinto natural sería hacer esto:
var mockFactory = new Mock<IHttpClientFactory>();
// ... y ya?
El inconveniente es que HttpClient no tiene una interfaz mockeable directamente para sus operaciones HTTP. Lo que realmente envía las requests es el HttpMessageHandler que vive adentro del cliente. Y encima, el método clave — SendAsync — es protected.
Por eso necesitamos el combo: mockear el handler, construir un cliente con ese handler, y recién después mockear la factory.
La solución: mockear HttpMessageHandler con Moq
Paso 1 — Crear el mock del handler
var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
MockBehavior.Strict hace que el mock lance una excepción si se llama a un método que no fue configurado explícitamente. Es una buena práctica: si tu test pasa silenciosamente pero hay llamadas no esperadas, Strict te avisa.
Paso 2 — Definir el comportamiento de SendAsync
Acá entra la magia de .Protected(). Como SendAsync es un método protected internal abstract, no podés accederlo directamente. Moq te da una puerta trasera:
var fakeResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{\"id\":1,\"name\":\"Producto de prueba\"}",
Encoding.UTF8, "application/json")
};
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(fakeResponse)
.Verifiable();
Con ItExpr.IsAny<>() capturamos cualquier request. Si necesitás ser más específico (verificar que se llamó a una URL en particular), podés usar ItExpr.Is<HttpRequestMessage> con una condición:
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Delete &&
req.RequestUri!.PathAndQuery.Contains("name=producto-test")
)
Paso 3 — Crear el HttpClient con ese handler
var httpClient = new HttpClient(handlerMock.Object)
{
BaseAddress = new Uri("https://api.miservicio.com/")
};
Paso 4 — Mockear IHttpClientFactory
var mockFactory = new Mock<IHttpClientFactory>();
mockFactory
.Setup(f => f.CreateClient("ext_service"))
.Returns(httpClient);
Importante: el string "ext_service" tiene que coincidir exactamente con el nombre que usa tu servicio al llamar CreateClient().
Paso 5 — Armar el test completo (patrón AAA)
[Fact]
public async Task DeleteObject_DeberiaLlamarAlEndpointCorrecto()
{
// Arrange
var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK })
.Verifiable();
var httpClient = new HttpClient(handlerMock.Object)
{
BaseAddress = new Uri("https://api.miservicio.com/")
};
var mockFactory = new Mock<IHttpClientFactory>();
mockFactory.Setup(f => f.CreateClient("ext_service")).Returns(httpClient);
var service = new MyExternalService(mockFactory.Object);
// Act
await service.DeleteObject("producto-test");
// Assert
handlerMock.Protected().Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Delete &&
req.RequestUri!.PathAndQuery.Contains("name=producto-test")
),
ItExpr.IsAny<CancellationToken>()
);
}
Typed Clients: el caso más simple
Si usás Typed Clients (el patrón donde el HttpClient se inyecta directamente en el constructor de tu clase), el setup es incluso más directo: no necesitás mockear IHttpClientFactory en absoluto.
public class ProductoApiClient
{
private readonly HttpClient _client;
public ProductoApiClient(HttpClient client)
{
_client = client;
}
public async Task<string> GetProductoAsync(int id)
{
var response = await _client.GetAsync($"/api/productos/{id}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
// En el test:
[Fact]
public async Task GetProducto_DeberiaRetornarJson()
{
// Arrange
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{\"id\":1,\"nombre\":\"Laptop\"}")
});
var httpClient = new HttpClient(handlerMock.Object)
{
BaseAddress = new Uri("https://api.productos.com/")
};
var client = new ProductoApiClient(httpClient);
// Act
var resultado = await client.GetProductoAsync(1);
// Assert
Assert.Contains("Laptop", resultado);
}
Alternativa: Moq.Contrib.HttpClient
Si el setup te parece verboso (y con razón), existe el paquete Moq.Contrib.HttpClient que simplifica bastante la sintaxis:
// Install: dotnet add package Moq.Contrib.HttpClient
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock
.SetupRequest(HttpMethod.Get, "https://api.productos.com/api/productos/1")
.ReturnsResponse(HttpStatusCode.OK, "{\"id\":1,\"nombre\":\"Laptop\"}", "application/json");
var httpClient = handlerMock.CreateClient(); // O handlerMock.CreateClientFactory() para IHttpClientFactory
var client = new ProductoApiClient(httpClient);
var resultado = await client.GetProductoAsync(1);
// Verificación también más limpia:
handlerMock.VerifyRequest(HttpMethod.Get, "https://api.productos.com/api/productos/1", Times.Once());
Resumen: ¿cuándo usar cada enfoque?
| Patrón | Cómo testear |
|---|---|
| Named Client (IHttpClientFactory) | Mock handler → HttpClient → Mock Factory |
| Typed Client (HttpClient inyectado) | Mock handler → HttpClient → inyectar directo |
| Quiero menos boilerplate | Moq.Contrib.HttpClient |
Conclusión
La clave está en entender que HttpClient delega el trabajo real a HttpMessageHandler, y ese handler es el punto donde interceptamos las llamadas HTTP en los tests. Una vez que tenés ese concepto claro, el resto es mecánica.
El resultado: tests rápidos, deterministas, que no fallan porque la API externa está caída o porque el ambiente de CI no tiene acceso a internet. Exactamente lo que necesitás para un pipeline de CI/CD confiable.
¿Usás otro approach para mockear HTTP en tus tests? Dejalo en los comentarios.