之前一篇AspNetCore服务端读写浏览器Cookie里使用了HttpResponse.OnStarting()方法通过注册回调来实现往Response里写入cookie。本篇我们看一下如何对其进行单元测试。

测试HttpResponse.OnStarting()

对HttpResponse进行测试&验证,离不开HttpContext以及一个Mock HttpResponse。首先我们尝试Mock一个HttpContext,并且Http Request和Response都直接用Mock。

1
2
3
var requestMock = new Mock<HttpRequest>();
var responseMock = new Mock<HttpResponse>();
var httpContextMock = new Mock<HttpContext>();

Mock HttpResponse.OnStarting()的Setup可以借助Moq Callback来获取最终调用的回调方法,并放在next里触发回调方法的调用[1],代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 Func<object, Task> callbackMethod = null;
_responseMock.Setup(m => m.OnStarting(It.IsAny<Func<object, Task>>(), It.IsAny<object>()))
.Callback<Func<object, Task>, object>((func, obj) => callbackMethod = func);

RequestDelegate next = async context =>
{
_isNextDelegateCalled = true;
if (callbackMethod != null)
{
await callbackMethod.Invoke(_httpContextMock.Object);
}
else
{
await Task.CompletedTask;
}
};

有了上面的Mock代码,下面的单元测试确实可以触发callback方法的调用了,但是测试却通不过,因为代码context.Response.Cookies.Append(targetCookieName, cookieString);将cookie写入response后,不管通过responseMock.Object.GetTypedHeaders().SetCookie还是_responseMock.Object.Headers["Set-Cookie"]都无法获取到cookie的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[Fact]
public async Task TestUpsertCookieMiddleware_WhenRequestContainsTargetCookie_ShouldResponseContainsUpdatedCookie()
{
// Given
var cookieValueTest = "key1=val1";
_requestCookieMock.Setup(m => m.TryGetValue("cookie11", out cookieValueTest)).Returns(true);

var expectResponseCookie = WebUtility.UrlEncode("key1=val1&newKey=newVal");

// When
await _upsertCookieMiddleware.Invoke(_httpContextMock.Object);

// Then
_isNextDelegateCalled.Should().BeTrue();
_responseMock.Object.GetTypedHeaders().SetCookie.First(c => c.Name.Value == "cookie11").Value.ToString()
.Should().Contain(expectResponseCookie);

// _responseMock.Object.Headers["Set-Cookie"].Should().Contain(expectResponseCookie);
}

因为上面的尝试无法让测试通过,我们不得不另寻他法。研究一番发现有个IHttpResponseFeature这样的接口也可以Mock Response的状态和信息[2],然后通过HttpContext.Features.Set()将Mock的Respnose注入。那现在我们借助DefaultHttpContext,这样可以解决上面遇到的问题。其实Mock IHttpResponseFeature和Mock HttpResponse差不多,代码如下:

1
2
3
4
5
6
7
8
9
10
var responseMock = new Mock<IHttpResponseFeature>();
_httpContextMock = new DefaultHttpContext();

// Mock HttpResponse
Func<object, Task> callbackMethod = null;
responseMock.Setup(m => m.OnStarting(It.IsAny<Func<object, Task>>(), It.IsAny<object>()))
.Callback<Func<object, Task>, object>((func, obj) => callbackMethod = func);
responseMock.Setup(m => m.Headers).Returns(new HeaderDictionary());

_httpContextMock.Features.Set(responseMock.Object);

基于上面新的Mock代码,下面的单元测试代码就可以顺利通过了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Fact]
public async Task TestUpsertCookieMiddleware_WhenRequestContainsTargetCookie_ShouldResponseContainsUpdatedCookie()
{
// Given
_httpContextMock.Request.Headers["Cookie"] = "cookie11=key1=val1";
var expectResponseCookie = WebUtility.UrlEncode("key1=val1&newKey=newVal");

// When
await _upsertCookieMiddleware.Invoke(_httpContextMock);

// Then
_isNextDelegateCalled.Should().BeTrue();
_httpContextMock.Response.GetTypedHeaders().SetCookie.First(c => c.Name.Value == "cookie11").Value.ToString()
.Should().Contain(expectResponseCookie);
}

扩展

仔细看的话,上面单元测试的代码里直接通过 _httpContextMock.Request.Headers["Cookie"] = "cookie11=key1=val1"; 来设置Http Request的cookie值,这就是采用DefaultHttpContext带来的便捷。
那么是否可以直接Mock Request里的cookie呢,答案是肯定的,有个IRequestCookiesFeature接口[3]可以用来Mock,然后直接注入DefaultHttpContext。代码如下:

1
2
3
4
5
6
7
_httpContextMock = new DefaultHttpContext();
var requestCookieFeatureMock = new Mock<IRequestCookiesFeature>();
var responseMock = new Mock<IHttpResponseFeature>();

_requestCookieCollectionMock = new Mock<IRequestCookieCollection>();
requestCookieFeatureMock.Setup(m => m.Cookies).Returns(_requestCookieCollectionMock.Object);
_httpContextMock.Features.Set(requestCookieFeatureMock.Object);

基于上面新的Mock代码,改造后的单元测试里直接Mock IRequestCookieCollection的TryGetValue[4], out值通过一个本地变量定义[5],测试顺利通过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[Fact]
public async Task TestUpsertCookieMiddleware_WhenRequestContainsTargetCookie_ShouldResponseContainsUpdatedCookie()
{
// Given
var cookieKeyTest = "cookie11";
var cookieValueTest = "key1=val1";

_requestCookieCollectionMock.Setup(m => m.TryGetValue(cookieKeyTest, out cookieValueTest)).Returns(true);

var expectResponseCookie = WebUtility.UrlEncode("key1=val1&newKey=newVal");

// When
await _upsertCookieMiddleware.Invoke(_httpContextMock);

// Then
_isNextDelegateCalled.Should().BeTrue();
_httpContextMock.Response.GetTypedHeaders().SetCookie.First(c => c.Name.Value == cookieKeyTest).Value.ToString()
.Should().Contain(expectResponseCookie);
}

除了本篇用到的IHttpResponseFeature、IRequestCookiesFeature,还有很多其他的Feature[6],例如IHttpRequestFeature等,可以借助它们做很多其他事情。

另外,上面的单元测试里校验cookie时借助了WebUtility.UrlEncode,它和HttpUtility.UrlEncode略有不同[7]。WebUtility encode后的符号的编码是大写,而HttpUtility则是小写。
UrlEncode WebUtility vs HttpUtility

结语

在进行HttpContext相关的单元测试时,优先借助DefaultHttpContext可以减少很多不必要的Mock代码。尽量借助相关的Feature[6:1]来进行Mock,也会达到事半功倍的效果。

源码

Demo代码已上传 https://github.com/jeremyLJ/aspnetcore_read_write_cookie/tree/main/aspnetcore_read_write_cookie_demo/WebApiTest


  1. https://www.appsloveworld.com/csharp/100/302/how-to-unit-test-a-net-middleware-that-uses-response-onstarting ↩︎

  2. https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.features.ihttpresponsefeature ↩︎

  3. https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.features.irequestcookiesfeature ↩︎

  4. https://github.com/dotnet/aspnetcore/issues/18193#issuecomment-572938829 ↩︎

  5. https://www.codeproject.com/Articles/5286617/Moq-and-out-Parameter ↩︎

  6. https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.features ↩︎ ↩︎

  7. https://edi.wang/post/2018/11/25/netcore-webutility-urlencode-httputility-urlencode ↩︎