原文: Testing Controller Logic
作者: Steve Smith
翻譯: 姚阿勇(Dr.Yao)
校對: 高嵩(Jack)
ASP.NET MVC 應用程序的控制器應當小巧並專注於用戶界面。涉及了非 UI 事務的大控制器更難於測試和維護。
章節:
在 GitHub 上查看或下載示例
為什么要測試控制器
控制器是所有 ASP.NET Core MVC 應用程序的核心部分。因此,你應當確保它們的行為符合應用的預期。 自動化測試可以為你提供這樣的保障並能夠在進入生產環境之前將錯誤檢測出來。重要的一點是,避免將非必要的職責加入你的控制器並且確保測試只關注在控制器的職責上。
控制器的邏輯應當最小化並且不要去關心業務邏輯或基礎事務(如,數據訪問)。要測試控制器的邏輯,而不是框架。根據有效或無效的輸入去測試控制器的 行為 如何。根據其執行業務操作的返回值去測試控制器的響應。
典型的控制器職責:
- 驗證
ModelState.IsValid - 如果
ModelState無效則返回一個錯誤響應 - 從持久層獲取一個業務實體
- 在業務實體上執行一個操作
- 將業務實體保存到持久層
- 返回一個合適的
IActionResult
單元測試
單元測試 包括對應用中獨立於基礎結構和依賴項之外的某一部分的測試。對控制器邏輯進行單元測試的時候,只測試一個操作的內容,而不測試其依賴項或框架本身的行為。就是說對你的控制器操作進行測試時,要確保只聚焦於操作本身的行為。控制器單元測試避開諸如 過濾器, 路由,or 模型綁定 這些內容。由於只專注於測試某一項內容,單元測試通常編寫簡單而運行快捷。一組編寫良好的單元測試可以無需過多開銷地頻繁運行。然而,單元測試並不檢測組件之間交互的問題,那是集成測試的目的。
如果你在編寫自定義的過濾器,路由,諸如此類,你應該對它們進行單元測試,但不是作為某個控制器操作測試的一部分。它們應該單獨進行測試。
為演示單元測試,請查看下面的控制器。它顯示一個頭腦風暴討論會的列表,並且可以用 POST 請求創建新的頭腦風暴討論會:
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.ViewModels;
namespace TestingControllersSample.Controllers
{
public class HomeController : Controller // 手動高亮
{
private readonly IBrainstormSessionRepository _sessionRepository;
public HomeController(IBrainstormSessionRepository sessionRepository) // 手動高亮
{
_sessionRepository = sessionRepository;
}
public async Task<IActionResult> Index() // 手動高亮
{
var sessionList = await _sessionRepository.ListAsync();
var model = sessionList.Select(session => new StormSessionViewModel()
{
Id = session.Id,
DateCreated = session.DateCreated,
Name = session.Name,
IdeaCount = session.Ideas.Count
});
return View(model);
}
public class NewSessionModel
{
[Required]
public string SessionName { get; set; }
}
[HttpPost] // 手動高亮
public async Task<IActionResult> Index(NewSessionModel model) // 手動高亮
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await _sessionRepository.AddAsync(new BrainstormSession()
{
DateCreated = DateTimeOffset.Now,
Name = model.SessionName
});
return RedirectToAction("Index");
}
}
}
這個控制器遵循顯式依賴原則,期望依賴注入為其提供一個 IBrainstormSessionRepository 的實例。這樣就非常容易用一個 Mock 對象框架來進行測試,比如 Moq 。HTTP GET Index 方法沒有循環或分支,只是調用了一個方法。要測試這個 Index 方法,我們需要驗證是否返回了一個 ViewResult ,其中包含一個來自存儲庫的 List 方法的 ViewModel 。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using TestingControllersSample.Controllers;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.ViewModels;
using Xunit;
namespace TestingControllersSample.Tests.UnitTests
{
public class HomeControllerTests
{
[Fact] // 手動高亮
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions() // 手動高亮
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
var controller = new HomeController(mockRepo.Object);
// Act
var result = await controller.Index();
// Assert
var viewResult = Assert.IsType<ViewResult>(result);
var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
viewResult.ViewData.Model);
Assert.Equal(2, model.Count());
}
[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
var controller = new HomeController(mockRepo.Object);
controller.ModelState.AddModelError("SessionName", "Required");
var newSession = new HomeController.NewSessionModel();
// Act
var result = await controller.Index(newSession);
// Assert
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.IsType<SerializableError>(badRequestResult.Value);
}
[Fact]
public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
.Returns(Task.CompletedTask)
.Verifiable();
var controller = new HomeController(mockRepo.Object);
var newSession = new HomeController.NewSessionModel()
{
SessionName = "Test Name"
};
// Act
var result = await controller.Index(newSession);
// Assert
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Null(redirectToActionResult.ControllerName);
Assert.Equal("Index", redirectToActionResult.ActionName);
mockRepo.Verify();
}
private List<BrainstormSession> GetTestSessions()
{
var sessions = new List<BrainstormSession>();
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 2),
Id = 1,
Name = "Test One"
});
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 1),
Id = 2,
Name = "Test Two"
});
return sessions;
}
}
}
HTTP POST Index 方法(下面所示)應當驗證:
- 當
ModelState.IsValid為false時,操作方法返回一個包含適當數據的ViewResult。 - 當
ModelState.IsValid為true時,存儲庫的Add方法被調用,然后返回一個包含正確變量內容的RedirectToActionResult。
[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
var controller = new HomeController(mockRepo.Object);
controller.ModelState.AddModelError("SessionName", "Required"); // 手動高亮
var newSession = new HomeController.NewSessionModel();
// Act
var result = await controller.Index(newSession);
// Assert
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result); // 手動高亮
Assert.IsType<SerializableError>(badRequestResult.Value); // 手動高亮
}
[Fact]
public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
.Returns(Task.CompletedTask)
.Verifiable();
var controller = new HomeController(mockRepo.Object);
var newSession = new HomeController.NewSessionModel()
{
SessionName = "Test Name"
};
// Act
var result = await controller.Index(newSession);
// Assert
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result); // 手動高亮
Assert.Null(redirectToActionResult.ControllerName); // 手動高亮
Assert.Equal("Index", redirectToActionResult.ActionName); // 手動高亮
mockRepo.Verify();
}
第一個測試確定當 ModelState 無效時,返回一個與 GET 請求一樣的 ViewResult 。注意,測試不會嘗試傳遞一個無效模型進去。那樣是沒有作用的,因為模型綁定並沒有運行 - 我們只是直接調用了操作方法。然而,我們並不想去測試模型綁定 —— 我們只是在測試操作方法里的代碼行為。最簡單的方法就是在 ModelState 中添加一個錯誤。
第二個測試驗證當 ModelState 有效時,新的 BrainstormSession 被添加(通過存儲庫),並且該方法返回一個帶有預期屬性值的 RedirectToActionResult 。未被執行到的 mock 調用通常就被忽略了,但是在設定過程的最后調用 Verifiable 則允許其在測試中被驗證。這是通過調用 mockRepo.Verify 實現的。
這個例子中所采用的 Moq 庫能夠簡單地混合可驗證的,“嚴格的”及帶有不可驗證mock(也稱為 “寬松的” mock 或 stub)的mock。了解更多關於 使用 Moq 自定義 Mock 行為。
應用程序里的另外一個控制器顯示指定頭腦風暴討論會的相關信息。它包含一些處理無效 id 值的邏輯:
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.ViewModels;
namespace TestingControllersSample.Controllers
{
public class SessionController : Controller
{
private readonly IBrainstormSessionRepository _sessionRepository;
public SessionController(IBrainstormSessionRepository sessionRepository)
{
_sessionRepository = sessionRepository;
}
public async Task<IActionResult> Index(int? id)
{
if (!id.HasValue) // 手動高亮
{ // 手動高亮
return RedirectToAction("Index", "Home"); // 手動高亮
} // 手動高亮
var session = await _sessionRepository.GetByIdAsync(id.Value);
if (session == null) // 手動高亮
{ // 手動高亮
return Content("Session not found."); // 手動高亮
} // 手動高亮
var viewModel = new StormSessionViewModel()
{
DateCreated = session.DateCreated,
Name = session.Name,
Id = session.Id
};
return View(viewModel);
}
}
}
這個控制器操作有三種情況要測試,每條 return 語句一種:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using TestingControllersSample.Controllers;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.ViewModels;
using Xunit;
namespace TestingControllersSample.Tests.UnitTests
{
public class SessionControllerTests
{
[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
// Arrange
var controller = new SessionController(sessionRepository: null);
// Act
var result = await controller.Index(id: null);
// Arrange
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result); // 手動高亮
Assert.Equal("Home", redirectToActionResult.ControllerName); // 手動高亮
Assert.Equal("Index", redirectToActionResult.ActionName); // 手動高亮
}
[Fact]
public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
{
// Arrange
int testSessionId = 1;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.Returns(Task.FromResult((BrainstormSession)null));
var controller = new SessionController(mockRepo.Object);
// Act
var result = await controller.Index(testSessionId);
// Assert
var contentResult = Assert.IsType<ContentResult>(result); // 手動高亮
Assert.Equal("Session not found.", contentResult.Content); // 手動高亮
}
[Fact]
public async Task IndexReturnsViewResultWithStormSessionViewModel()
{
// Arrange
int testSessionId = 1;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.Returns(Task.FromResult(GetTestSessions().FirstOrDefault(s => s.Id == testSessionId)));
var controller = new SessionController(mockRepo.Object);
// Act
var result = await controller.Index(testSessionId);
// Assert
var viewResult = Assert.IsType<ViewResult>(result); // 手動高亮
var model = Assert.IsType<StormSessionViewModel>(viewResult.ViewData.Model); // 手動高亮
Assert.Equal("Test One", model.Name); // 手動高亮
Assert.Equal(2, model.DateCreated.Day); // 手動高亮
Assert.Equal(testSessionId, model.Id); // 手動高亮
}
private List<BrainstormSession> GetTestSessions()
{
var sessions = new List<BrainstormSession>();
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 2),
Id = 1,
Name = "Test One"
});
sessions.Add(new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 1),
Id = 2,
Name = "Test Two"
});
return sessions;
}
}
}
這個應用程序以 Web API (一個頭腦風暴討論會的意見列表以及一個給討論會添加新意見的方法)的形式公開功能:
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using TestingControllersSample.ClientModels;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
namespace TestingControllersSample.Api
{
[Route("api/ideas")]
public class IdeasController : Controller
{
private readonly IBrainstormSessionRepository _sessionRepository;
public IdeasController(IBrainstormSessionRepository sessionRepository)
{
_sessionRepository = sessionRepository;
}
[HttpGet("forsession/{sessionId}")] // 手動高亮
public async Task<IActionResult> ForSession(int sessionId) // 手動高亮
{
var session = await _sessionRepository.GetByIdAsync(sessionId);
if (session == null)
{
return NotFound(sessionId); // 手動高亮
}
var result = session.Ideas.Select(idea => new IdeaDTO()// 手動高亮
{ // 手動高亮
Id = idea.Id, // 手動高亮
Name = idea.Name, // 手動高亮
Description = idea.Description, // 手動高亮
DateCreated = idea.DateCreated // 手動高亮
}).ToList(); // 手動高亮
return Ok(result);
}
[HttpPost("create")] // 手動高亮
public async Task<IActionResult> Create([FromBody]NewIdeaModel model) // 手動高亮
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState); // 手動高亮
}
var session = await _sessionRepository.GetByIdAsync(model.SessionId);
if (session == null)
{
return NotFound(model.SessionId); // 手動高亮
}
var idea = new Idea()
{
DateCreated = DateTimeOffset.Now,
Description = model.Description,
Name = model.Name
};
session.AddIdea(idea);
await _sessionRepository.UpdateAsync(session);
return Ok(session); // 手動高亮
}
}
}
ForSession 方法返回一個 IdeaDTO 類型的列表,該類型有着符合 JavaScript 慣例的駝峰命名法的屬性名。從而避免直接通過 API 調用返回你業務領域的實體,因為通常它們都包含了 API 客戶端並不需要的更多數據,而且它們將你的應用程序的內部領域模型與外部公開的 API 不必要地耦合起來。可以手動將業務領域實體與你想要返回的類型連接映射起來(使用這里展示的 LINQ Select),或者使用諸如 AutoMapper的類庫。
Create 和 ForSession API 方法的單元測試:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using TestingControllersSample.Api;
using TestingControllersSample.ClientModels;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using Xunit;
namespace TestingControllersSample.Tests.UnitTests
{
public class ApiIdeasControllerTests
{
[Fact]
public async Task Create_ReturnsBadRequest_GivenInvalidModel() // 手動高亮
{
// Arrange & Act
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
controller.ModelState.AddModelError("error","some error"); // 手動高亮
// Act
var result = await controller.Create(model: null);
// Assert
Assert.IsType<BadRequestObjectResult>(result); // 手動高亮
}
[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()// 手動高亮
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) // 手動高亮
.Returns(Task.FromResult((BrainstormSession)null)); // 手動高亮
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.Create(new NewIdeaModel()); // 手動高亮
// Assert
Assert.IsType<NotFoundObjectResult>(result);
}
[Fact]
public async Task Create_ReturnsNewlyCreatedIdeaForSession() // 手動高亮
{
// Arrange
int testSessionId = 123;
string testName = "test name";
string testDescription = "test description";
var testSession = GetTestSession();
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) // 手動高亮
.Returns(Task.FromResult(testSession)); // 手動高亮
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = testSessionId
};
mockRepo.Setup(repo => repo.UpdateAsync(testSession)) // 手動高亮
.Returns(Task.CompletedTask) // 手動高亮
.Verifiable(); // 手動高亮
// Act
var result = await controller.Create(newIdea);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result); // 手動高亮
var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);// 手動高亮
mockRepo.Verify(); // 手動高亮
Assert.Equal(2, returnSession.Ideas.Count());
Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
}
[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.Returns(Task.FromResult((BrainstormSession)null));
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSession(testSessionId);
// Assert
var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
Assert.Equal(testSessionId, notFoundObjectResult.Value);
}
[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)).Returns(Task.FromResult(GetTestSession()));
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSession(testSessionId);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
var idea = returnValue.FirstOrDefault();
Assert.Equal("One", idea.Name);
}
private BrainstormSession GetTestSession()
{
var session = new BrainstormSession()
{
DateCreated = new DateTime(2016, 7, 2),
Id = 1,
Name = "Test One"
};
var idea = new Idea() { Name = "One" };
session.AddIdea(idea);
return session;
}
}
}
如前所述,要測試這個方法在 ModelState 無效時的行為,可以將一個模型錯誤作為測試的一部分添加到控制器。不要在單元測試嘗試測試模型驗證或者模型綁定 —— 僅僅測試應對特定 ModelState 值的時候,你的操作方法的行為。
第二項測試需要存儲庫返回 null ,因此將模擬的存儲庫配置為返回 null 。沒有必要去創建一個測試數據庫(內存中的或其他的)並構建一條能返回這個結果的查詢 —— 就像展示的那樣,一行代碼就可以了。.
最后一項測試驗證存儲庫的 Update 方法是否被調用。像我們之前做過的那樣,在調用 mock 時調用了 Verifiable ,然后模擬存儲庫的 Verify 方法被調用,用以確認可驗證的方法已被執行。確保 Update 保存了數據並不是單元測試的職責;那是集成測試做的事。
集成測試
集成測試是為了確保你應用程序里各獨立模塊能夠正確地一起工作。通常,能進行單元測試的東西,都能進行集成測試,但反之則不行。不過,集成測試往往比單元測試慢得多。因此,最好盡量采用單元測試,在涉及到多方合作的情況下再進行集成測試。
盡管 mock 對象仍然有用,但在集成測試中很少用到它們。在單元測試中,mock 對象是一種有效的方式,根據測試目的去控制測試單元外的合作者應當有怎樣的行為。在集成測試中,則采用真實的合作者來確定整個子系統能夠正確地一起工作。
應用程序狀態
在執行集成測試的時候,一個重要的考慮因素就是如何設置你的應用程序的狀態。各個測試需要獨立地運行,所以每個測試都應該在已知狀態下隨應用程序啟動。如果你的應用沒有使用數據庫或者任何持久層,這可能不是個問題。然而,大多數真實的應用程序都會將它們的狀態持久化到某種數據存儲中,所以某個測試對其有任何改動都可能影響到其他測試,除非重置了數據存儲。使用內置的 TestServer ,它可以直接托管我們集成測試中的 ASP.NET Core 應用程序,但又無須對我們將使用的數據授權訪問。如果你正在使用真實的數據庫,一種方法是讓應用程序連接到測試數據庫,你的測試可以訪問它並且確保在每個測試執行之前會重置到一個已知的狀態。
在這個示例應用程序里,我采用了 Entity Framework Core 的 InMemoryDatabase 支持,因此我可以直接把我的測試項目連接到它。實際上,我在應用程序的 Startup 類里公開了一個 InitializeDatabase 方法,我可以在開發( Development )環境中啟動應用程序的時候調用這個方法。我的集成測試只要把環境設置為 Development ,就能自動從中受益。我不需要擔心重置數據庫,因為 InMemoryDatabase 會在應用程序每次重啟的時候重置。
The Startup class:
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.Infrastructure;
namespace TestingControllersSample
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<AppDbContext>( // 手動高亮
optionsBuilder => optionsBuilder.UseInMemoryDatabase());// 手動高亮
services.AddMvc();
services.AddScoped<IBrainstormSessionRepository,
EFStormSessionRepository>();
}
public void Configure(IApplicationBuilder app,
IHostingEnvironment env,
ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole(LogLevel.Warning);
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
var repository = app.ApplicationServices.GetService<IBrainstormSessionRepository>();// 手動高亮
InitializeDatabaseAsync(repository).Wait(); // 手動高亮
}
app.UseStaticFiles();
app.UseMvcWithDefaultRoute();
}
public async Task InitializeDatabaseAsync(IBrainstormSessionRepository repo) // 手動高亮
{
var sessionList = await repo.ListAsync();
if (!sessionList.Any())
{
await repo.AddAsync(GetTestSession());
}
}
public static BrainstormSession GetTestSession() // 手動高亮
{
var session = new BrainstormSession()
{
Name = "Test Session 1",
DateCreated = new DateTime(2016, 8, 1)
};
var idea = new Idea()
{
DateCreated = new DateTime(2016, 8, 1),
Description = "Totally awesome idea",
Name = "Awesome idea"
};
session.AddIdea(idea);
return session;
}
}
}
在下面的集成測試中,你會看到 GetTestSession 方法被頻繁使用。
訪問視圖
每一個集成測試類都會配置 TestServer 來運行 ASP.NET Core 應用程序。默認情況下,TestServer 在其運行的目錄下承載 Web 應用程序 —— 在本例中,就是測試項目文件夾。因此,當你嘗試測試返回 ViewResult 的控制器操作的時候,你會看見這樣的錯誤:
未找到視圖 “Index”。已搜索以下位置:
(位置列表)
要修正這個問題,你需要配置服務器使其采用 Web 項目的 ApplicationBasePath 和 ApplicationName 。這在所示的集成測試類中調用 UseServices 完成的:
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;
namespace TestingControllersSample.Tests.IntegrationTests
{
public class HomeControllerTests : IClassFixture<TestFixture<TestingControllersSample.Startup>>
{
private readonly HttpClient _client;
public HomeControllerTests(TestFixture<TestingControllersSample.Startup> fixture)
{
_client = fixture.Client;
}
[Fact]
public async Task ReturnsInitialListOfBrainstormSessions()
{
// Arrange
var testSession = Startup.GetTestSession();
// Act
var response = await _client.GetAsync("/");
// Assert
response.EnsureSuccessStatusCode();
var responseString = await response.Content.ReadAsStringAsync();
Assert.True(responseString.Contains(testSession.Name));
}
[Fact]
public async Task PostAddsNewBrainstormSession()
{
// Arrange
string testSessionName = Guid.NewGuid().ToString();
var data = new Dictionary<string, string>();
data.Add("SessionName", testSessionName);
var content = new FormUrlEncodedContent(data);
// Act
var response = await _client.PostAsync("/", content);
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("/", response.Headers.Location.ToString());
}
}
}
在上面的測試中,responseString 從視圖獲取真實渲染的 HTML ,可以用來檢查確認其中是否包含期望的結果。
API 方法
如果你的應用程序有公開的 Web API,采用自動化測試來確保它們按期望執行是個好主意。內置的 TestServer 便於測試 Web API。如果你的 API 方法使用了模型綁定,那么你應該始終檢查 ModelState.IsValid ,另外確認你的模型驗證工作是否正常應當在集成測試里進行。
下面一組測試針對上文所示的 ideasController里的 Create 方法:
[Fact]
public async Task CreatePostReturnsBadRequestForMissingNameValue()
{
// Arrange
var newIdea = new NewIdeaDto("", "Description", 1);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsBadRequestForMissingDescriptionValue()
{
// Arrange
var newIdea = new NewIdeaDto("Name", "", 1);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsBadRequestForSessionIdValueTooSmall()
{
// Arrange
var newIdea = new NewIdeaDto("Name", "Description", 0);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsBadRequestForSessionIdValueTooLarge()
{
// Arrange
var newIdea = new NewIdeaDto("Name", "Description", 1000001);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsNotFoundForInvalidSession()
{
// Arrange
var newIdea = new NewIdeaDto("Name", "Description", 123);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task CreatePostReturnsCreatedIdeaWithCorrectInputs()
{
// Arrange
var testIdeaName = Guid.NewGuid().ToString();
var newIdea = new NewIdeaDto(testIdeaName, "Description", 1);
// Act
var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
// Assert
response.EnsureSuccessStatusCode();
var returnedSession = await response.Content.ReadAsJsonAsync<BrainstormSession>();
Assert.Equal(2, returnedSession.Ideas.Count);
Assert.True(returnedSession.Ideas.Any(i => i.Name == testIdeaName));
}
[Fact]
public async Task ForSessionReturnsNotFoundForBadSessionId()
{
// Arrange & Act
var response = await _client.GetAsync("/api/ideas/forsession/500");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
public async Task ForSessionReturnsIdeasForValidSessionId()
{
// Arrange
var testSession = Startup.GetTestSession();
// Act
var response = await _client.GetAsync("/api/ideas/forsession/1");
// Assert
response.EnsureSuccessStatusCode();
var ideaList = JsonConvert.DeserializeObject<List<IdeaDTO>>(
await response.Content.ReadAsStringAsync());
var firstIdea = ideaList.First();
Assert.Equal(testSession.Ideas.First().Name, firstIdea.Name);
}
}
不同於對返回 HTML 視圖的操作的集成測試,有返回值的 Web API 方法通常能夠反序列化為強類型對象,就像上面所示的最后一個測試。在此例中,該測試將返回值反序列化為一個 BrainstormSession 實例,然后再確認意見是否被正確添加到了意見集合里。
你可以在sample project這篇文章里找到更多的集成測試示例。
