1. 程式人生 > >ASP.NET Core 中文文件 第四章 MVC(4.5)測試控制器邏輯

ASP.NET Core 中文文件 第四章 MVC(4.5)測試控制器邏輯

ASP.NET MVC 應用程式的控制器應當小巧並專注於使用者介面。涉及了非 UI 事務的大控制器更難於測試和維護。

章節:

為什麼要測試控制器

控制器是所有 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 物件框架來進行測試,比如 MoqHTTP 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.IsValidfalse 時,操作方法返回一個包含適當資料的 ViewResult
  • ModelState.IsValidtrue 時,儲存庫的 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的類庫。

CreateForSession 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 專案的 ApplicationBasePathApplicationName 。這在所示的整合測試類中呼叫 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這篇文章裡找到更多的整合測試示例。

返回目錄

相關推薦

ASP.NET Core 中文 MVC4.5測試控制器邏輯

ASP.NET MVC 應用程式的控制器應當小巧並專注於使用者介面。涉及了非 UI 事務的大控制器更難於測試和維護。 章節: 為什麼要測試控制器 控制器是所有 ASP.NET Core MVC 應用程式的核心部分。因此,你應當確保它們的行為符合應用的預期。 自動化測試可以為你提供這樣的保障並能夠在進入生

ASP.NET Core 中文 MVC4.6Areas區域

Areas 是 ASP.NET MVC 用來將相關功能組織成一組單獨名稱空間(路由)和資料夾結構(檢視)的功能。使用 Areas 建立層次結構的路由,是通過新增另一個路由引數 area 到 Controller 和 action。 Areas 提供了一種把大型 ASP.NET Core MVC Web 應用

ASP.NET Core 中文 MVC4.2控制器操作的路由

ASP.NET Core MVC 使用路由 中介軟體 來匹配傳入請求的 URL 並對映到具體的操作。路由通過啟動程式碼或者特性定義。路由描述 URL 路徑應該如何匹配到操作。路由也同樣用於生成響應中返回的 URL(用於連結)。 這篇文章將解釋 MVC 和路由之間的相互作用,以及典型的 MVC 應用程式如何使

ASP.NET Core 中文 MVC4.1Controllers, Actions 和 Action Results

Action 和 action result 是開發者使用 ASP.NET MVC 構建應用程式的基礎部分。 什麼是 Controller 在 ASP.NET MVC 中, 控制器( Controller  )用於定義和聚合操作(Action)的一個集合。操作( 或操作方法 )是控制器中處理入站請求的一個方

ASP.NET Core 中文 MVC4.3過濾器

ASP.NET MVC 過濾器 可在執行管道的前後特定階段執行程式碼。過濾器可以配置為全域性有效、僅對控制器有效或是僅對 Action 有效。 過濾器如何工作? 不同的過濾器型別會在執行管道的不同階段執行,因此它們各自有一套適用場景。根據你實際要解決的問題以及在請求管道中執行的位置來選擇建立不同的過濾器。

ASP.NET Core 中文 MVC2.3格式化響應資料

ASP.NET Core MVC 內建支援對相應資料(response data)的格式化,用來修正格式或生成客戶端指定的格式。 特定格式的操作結果 某些操作結果(Action result)的型別是指定的特定格式,比如 JsonResult 或 ContentResult。Action 可以返回格式化為

ASP.NET Core 中文 MVC4.4依賴注入和控制器

ASP.NET Core MVC 控制器應通過它們的構造器明確的請求它們的依賴關係。在某些情況下,單個控制器的操作可能需要一個服務,在控制器級別上的請求可能沒有意義。在這種情況下,你也可以選擇將服務作為 action 方法的引數。 章節: 依賴注入 依賴注入(Dependency injection,

ASP.NET Core 中文 MVC3.8檢視中的依賴注入

ASP.NET Core 支援在檢視中使用 依賴注入 。這將有助於提供檢視專用的服務,比如本地化或者僅用於填充檢視元素的資料。你應該儘量保持控制器和檢視間的關注點分離(separation of concerns)。你的檢視所顯示的大部分資料應該從控制器傳入。 章節: 一個簡單的示例 你可以使用 @i

ASP.NET Core 中文 MVC3.7 區域性檢視partial

ASP.NET Core MVC 支援區域性檢視,當你需要在多個不同檢視間重用同一個頁面部件時會顯得特別有用。 什麼是區域性檢視? 區域性檢視是在其它檢視中被渲染的檢視。區域性檢視執行後生成的 HTML 結果會被渲染到呼叫方檢視或父檢視中。跟檢視檔案一樣,區域性檢視檔案也使用 .cshtml 作為副檔名。

ASP.NET Core 中文 MVC3.9檢視元件

章節: 介紹檢視元件 檢視元件是 ASP.NET Core MVC 中的新特性,與區域性檢視相似,但是它們更加的強大。檢視元件不使用模型繫結,只取決於呼叫它時所提供的資料。檢視元件有以下特點: 渲染一個塊,而不是整個響應 在控制器和檢視之間同樣包含了關注點分離和可測試性帶來的好處 可以擁有引數和業務邏

ASP.NET Core 中文 MVC01ASP.NET Core MVC 概覽

ASP.NET Core MVC 是使用模型-檢視-控制器(Model-View-Controller)設計模式構建網頁應用與 API 的豐富的框架。 什麼是 MVC 模式? 模型-檢視-控制器(MVC)架構模式將一個應用區分為三部分主要元件:模型、檢視、與控制器。這種模式有助實現關注分離。使用這種模式,使

ASP.NET Core 中文 原理4路由

路由是用來把請求對映到路由處理程式。應用程式一啟動就配置了路由,並且可以從URL中提取值用於處理請求。它還負責使用 ASP.NET 應用程式中定義的路由來生成連結。 這份文件涵蓋了初級的ASP.NET核心路由。對於 ASP.NET 核心 MVC 路由, 請檢視 Routing to Controller A

ASP.NET Core 中文 原理2中介軟體

章節: 什麼是中介軟體 中介軟體是用於組成應用程式管道來處理請求和響應的元件。管道內的每一個元件都可以選擇是否將請求交給下一個元件、並在管道中呼叫下一個元件之前和之後執行某些操作。請求委託被用來建立請求管道,請求委託處理每一個 HTTP 請求。 請求委託通過使用 IApplicationBuilder

ASP.NET Core 中文 原理6全球化與本地化

使用 ASP.NET Core 建立一個多語言版本的網站有助於你吸引到更多的使用者,ASP.NET Core 提供服務和中介軟體來支援本地化語言和文化。 國際化涉及 全球化 和 本地化。全球化是為了應用程式支援不同文化而設計的。全球化增加了對特定地理區域的語言文字的輸入、顯示和輸出的支援。 本地化是針對一個

ASP.NET Core 中文 原理11在多個環境中工作

ASP.NET Core 介紹了支援在多個環境中管理應用程式行為的改進,如開發(development),預演(staging)和生產(production)。環境變數用來指示應用程式正在執行的環境,允許應用程式適當地配置。 章節: 開發,預演,生產 ASP.NET Core 引用了一個特定的環境變數

ASP.NET Core 中文 原理13管理應用程式狀態

在 ASP.NET Core 中,有多種途徑可以對應用程式的狀態進行管理,取決於檢索狀態的時機和方式。本文簡要介紹幾種可選的方式,並著重介紹為 ASP.NET Core 應用程式安裝並配置會話狀態支援。 應用程式狀態的可選方式 應用程式狀態 指的是用於描述應用程式當前狀況的任意資料。包括全域性的和使用者特

ASP.NET Core 中文 原理1應用程式啟動

ASP.NET Core 為你的應用程式提供了處理每個請求的完整控制。Startup 類是應用程式的入口(entry point),這個類可以設定配置(configuration)並且將應用程式將要使用的服務連線起來。開發人員可以在 Startup 類中配置請求管道,該管道將用於處理應用程式的所有請求。 章

ASP.NET Core 中文 原理16.NET開放Web介面OWIN

ASP.NET Core 支援 OWIN(即 Open Web Server Interface for .NET 的首字母縮寫),OWIN的目標是用於解耦Web Server和Web Application。此外, OWIN為中介軟體定義了一個標準方法用來處理單個請求以及相關聯的響應。ASP.NET Co

ASP.NET Core 中文 原理17為你的伺服器選擇合適版本的.NET框架

ASP.NET Core基於 .NET Core 專案模型,它支援構建能夠執行在 Windows、Mac和 Linux 上的跨平臺應用程式。當您構建一個 .Net Core 專案的時候,您可以選擇一種 .NET框架來構建您的應用程式,.NET Framework (CLR)、 .NET Core (Core

ASP.NET Core 中文 測試5.2整合測試

整合測試確保應用程式的元件組裝在一起時正常工作。 ASP.NET Core支援使用單元測試框架和可用於處理沒有網路開銷請求的內建測試的網路主機整合測試。 章節: 整合測試介紹 整合測試驗證應用程式不同的部位是否正確地組裝在一起。不像單元測試,整合測試經常涉及到應用基礎設施,如資料庫,檔案系統,網路資源