単体テストでは、アプリの一部をインフラストラクチャや依存関係から切り離してテストします。 コントローラーのロジックを単体テストする場合、テストされるのは 1 つのアクションの内容のみであり、その依存先やフレームワーク自体の動作はテストされません。
コントローラーの単体テスト
コントローラーのアクションの単体テストは、コントローラーの動作に注目するように設定します。 コントローラーの単体テストでは、フィルター、ルーティング、モデル バインドなどのシナリオは除外します。 まとまって要求に応答するコンポーネント間のインタラクションをカバーするテストは、統合テストです。 統合テストの詳細については、ASP.NET Core での統合テスト を参照してください。
カスタム フィルターやルートを作成する場合は、特定のコントローラー アクションのテストの一部としてではなく、それらを個別に単体テストしてください。
コントローラーの単体テストを理解するために、次のサンプル アプリ内のコントローラーを確認してください。
サンプル コードを表示またはダウンロードします (ダウンロードする方法)
Home コントローラーは、ブレーンストーミング セッションの一覧を表示し、POST 要求で新しいブレーンストーミング セッションを作成できるようにします。
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);
}
else
{
await _sessionRepository.AddAsync(new BrainstormSession()
{
DateCreated = DateTimeOffset.Now,
Name = model.SessionName
});
}
return RedirectToAction(actionName: nameof(Index));
}
}
上記のコントローラー:
- 明示的な依存関係の原則に従います。
-
依存関係の注入(DI) により
IBrainstormSessionRepositoryのインスタンスが提供されることを想定します。 -
Moq などのモック オブジェクト フレームワークを使用して、モックされた
IBrainstormSessionRepositoryサービスでテストできます。 モック オブジェクトは、プロパティとメソッドの動作のセットがあらかじめ定義された、テスト用の模擬オブジェクトです。 詳細については、「Introduction to integration tests」(統合テストの概要) を参照してください。
HTTP GET Index メソッドにはループや分岐はなく、1 つのメソッドを呼び出すだけです。 このアクションの単体テスト:
-
IBrainstormSessionRepositoryサービスをGetTestSessionsメソッドでモックする。GetTestSessionsが日付とセッション名を持つ 2 つのモック ブレーンストーミング セッションを作成する。 -
Indexメソッドを実行する。 - メソッドによって返された結果に関するアサーションを行う。
- ViewResult が返される。
-
ViewDataDictionary.Model は
StormSessionViewModelです。 -
ViewDataDictionary.Modelに格納された 2 つのブレーンストーミング セッションがある。
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync())
.ReturnsAsync(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());
}
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;
}
Home コントローラーの HTTP POST Index メソッドは、以下を検証します。
-
ModelState.IsValid が false
falseの場合、アクション メソッドは、適切なデータを含む 400 Bad RequestViewResult を返します。 -
ModelState.IsValidがtrueの場合:- リポジトリの
Addメソッドが呼び出される。 - 適切な引数を持つ RedirectToActionResult が返される。
- リポジトリの
下の最初のテストに示すように、AddModelError を使用してエラーを追加することで、モデルの無効状態をテストできます。
[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync())
.ReturnsAsync(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 が返されます。 このテストでは、無効なモデルを渡そうとはしていません。 モデルバインディングが実行されていないため、無効なモデルを渡すことは適切な方法ではありません(ただし、統合テストではモデルバインディングが行われます)。 ここでは、モデル バインドはテストされていません。 これらの単体テストでは、アクション メソッドのコードだけをテストしています。
2番目のテストでは、ModelState が有効であることを検証します:
- 新しい
BrainstormSessionが追加される (リポジトリ経由)。 - メソッドは、想定されたプロパティを持つ
RedirectToActionResultを返します。
通常、実際には呼び出されないモック呼び出しは無視されますが、セットアップ呼び出しの最後で Verifiable を呼び出すと、テストでモックの検証を行えるようになります。 これは mockRepo.Verify の呼び出しで実行され、予期されるメソッドが呼び出されないとテストは失敗します。
Note
このサンプルで使われている Moq ライブラリにより、検証可能な ("厳密な") モックと非検証可能なモック ("厳密でない" モックまたはスタブとも呼ばれます) を混在させることができます。 詳しくは、Moq の「Customizing Mock Behavior」(モックの動作のカスタマイズ) をご覧ください。
サンプル アプリの SessionController は、特定のブレーンストーミング セッションに関する情報を表示します。 このコントローラーには、無効な id 値に対処するロジックが含まれています(次の例には、これらのケースをカバーするために 2 つの return シナリオが含まれています)。 最後の return 文は、新しい StormSessionViewModel をビュー (Controllers/SessionController.cs) に返します。
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(actionName: nameof(Index),
controllerName: "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);
}
}
単体テストには、Session コントローラーの Index アクションにおける各 return シナリオに対して 1 件のテストが含まれます:
[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
// Arrange
var controller = new SessionController(sessionRepository: null);
// Act
var result = await controller.Index(id: null);
// Assert
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))
.ReturnsAsync((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))
.ReturnsAsync(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);
}
次に、Ideas コントローラーでは、アプリは機能を api/ideas ルート上の Web API として公開しています。
- ブレーンストーミング セッションに関連付けられたアイデアの一覧 (
IdeaDTO) が、ForSessionメソッドによって返される。 -
Createメソッドは、セッションに新しいアイデアを追加します。
[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);
}
API 呼び出しを介してビジネスドメインエンティティを直接返すことは避けてください。 ドメインエンティティ:
- 多くの場合、クライアントが必要とするもの以上のデータを含みます。
- アプリの内部ドメイン モデルと一般公開されている API を不必要に結合しないでください。
ドメイン エンティティとクライアントに返される型の間のマッピングは、以下のように実行できます。
- サンプル アプリで使用しているように、LINQ
Selectを使用して手動で。 詳細については、「LINQ (統合言語クエリ)」を参照してください。 - AutoMapper などのライブラリを使用して自動的に。
次のサンプル アプリは、Ideas コントローラーの Create と ForSession API メソッドの単体テストを示します。
このサンプル アプリには、2 つの ForSession テストが含まれています。 最初のテストでは、無効なセッションに対して ForSession が NotFoundObjectResult (HTTP Not Found) を返すかどうかを判断します。
[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((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);
}
2 番目の ForSession テストでは、有効なセッションに対して ForSession がセッションのアイデアの一覧 (<List<IdeaDTO>>) を返すかどうかを確認します。 これらのチェックでは、最初のアイデアを調べて、その Name プロパティが正しいことも確認します。
[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(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);
}
ModelState が無効な場合の Create メソッドの動作をテストするため、サンプル アプリでは、テストの一部としてコントローラーにモデル エラーを追加します。 単体テストでは、モデルの検証またはモデル バインドのテストを試さないでください。無効な ModelState が与えられた場合のアクション メソッドの動作だけをテストしてください:
[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);
}
Create の 2 番目のテストでは、リポジトリが null を返すことに依存しているため、null を返すようにモック リポジトリが構成されます。 テスト データベース (メモリ内またはそれ以外の場所) を作成し、この結果を返すクエリを作成する必要はありません。 サンプル コードに示すように、このテストは単一のステートメントで実行できます。
[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((BrainstormSession)null);
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.Create(new NewIdeaModel());
// Assert
Assert.IsType<NotFoundObjectResult>(result);
}
3 番目の Create テスト (Create_ReturnsNewlyCreatedIdeaForSession) では、リポジトリの UpdateAsync メソッドが呼び出されることを検証します。 モックが Verifiable で呼び出され、モック リポジトリの Verify メソッドが呼び出されて、検証対象のメソッドが実行されたことを確認します。
UpdateAsync メソッドがデータを保存したことを確認するのは、単体テストの役割ではありません。それは統合テストで確認できます。
[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))
.ReturnsAsync(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);
}
Test ActionResult<T>
ActionResult<T> (ActionResult<TValue>) は、ActionResult から派生した型を返したり、特定の型を返したりできます。
サンプル アプリには、特定のセッション id に対して List<IdeaDTO> を返すメソッドが含まれています。 セッションに id が存在しない場合、コントローラーは NotFound を返します。
[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(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 result;
}
ApiIdeasControllerTestsには、ForSessionActionResult コントローラーの 2 つのテストが含まれています。
最初のテストでは、コントローラーは ActionResult を返すが、存在しないセッション id の存在しないアイデアの一覧は返さないことを確認します。
-
ActionResultの型はActionResult<List<IdeaDTO>>。 - Result は NotFoundObjectResult です。
[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
var nonExistentSessionId = 999;
// Act
var result = await controller.ForSessionActionResult(nonExistentSessionId);
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}
有効なセッション id に対する 2 番目のテストでは、メソッドが以下を返すことを確認します。
-
List<IdeaDTO>型のActionResult。 -
ActionResult<T>.Value は
List<IdeaDTO>型。 - 一覧の最初の項目は、モック セッションに格納されているアイデアと一致する有効なアイデア (
GetTestSessionの呼び出しによって取得)。
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(GetTestSession());
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSessionActionResult(testSessionId);
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
var idea = returnValue.FirstOrDefault();
Assert.Equal("One", idea.Name);
}
このサンプル アプリには、特定のセッションで新しい Idea を作成するメソッドも含まれています。 コントローラーは以下を返します。
- BadRequest は無効なモデル用です。
- NotFound(セッションが存在しない場合)。
- セッションが新しいアイデアで更新されたときに CreatedAtAction。
[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([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 CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}
ApiIdeasControllerTests には、CreateActionResult のテストが 3 つ含まれます。
最初のテキストでは、無効なモデルに対して BadRequest が返されることを確認しています。
[Fact]
public async Task CreateActionResult_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.CreateActionResult(model: null);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}
2番目のテストでは、セッションが存在しない場合に NotFound が返されることを確認します。
[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
// Arrange
var nonExistentSessionId = 999;
string testName = "test name";
string testDescription = "test description";
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = nonExistentSessionId
};
// Act
var result = await controller.CreateActionResult(newIdea);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}
有効なセッション id に対しては、最後のテストで以下を確認します。
- メソッドは、
BrainstormSession型のActionResultを返します。 -
ActionResult<T>.Result は CreatedAtActionResult です。
CreatedAtActionResultは、Locationヘッダーを含む 201 Created 応答に相当します。 -
ActionResult<T>.Value は
BrainstormSession型。 - セッション
UpdateAsync(testSession)を更新するモック 呼び出しが呼び出された。Verifiableメソッド呼び出しは、アサーション内でmockRepo.Verify()を実行することでチェックされます。 - そのセッションについて 2 つの
Ideaオブジェクトが返されます。 - 最後の項目(
UpdateAsyncへのモック呼び出しによって追加されたIdea)が、テスト中にセッションに追加されたnewIdeaと一致する。
[Fact]
public async Task CreateActionResult_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))
.ReturnsAsync(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.CreateActionResult(newIdea);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
mockRepo.Verify();
Assert.Equal(2, returnValue.Ideas.Count());
Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}
コントローラーは、すべての ASP.NET Core MVC アプリで中心的な役割を担います。 そのため、コントローラーが意図したとおりに動作することに自信を持てるはずです。 自動テストによって、アプリが運用環境にデプロイされる前にエラーを検出できます。
サンプル コードを表示またはダウンロードします (ダウンロードする方法)
コントローラー ロジックの単体テスト
単体テストでは、アプリの一部をインフラストラクチャや依存関係から切り離してテストすることが必要とされます。 コントローラー ロジックの単体テストを行うと、単一のアクションの内容のみがテストされ、その依存関係やフレームワーク自体の動作はテストされません。
コントローラー アクションの単体テストは、コントローラーの動作に焦点を当てるように設定してください。 コントローラーの単体テストでは、フィルター、ルーティング、モデル バインドなどのシナリオは除外します。 まとまって要求に応答するコンポーネント間のインタラクションをカバーするテストは、統合テストで扱われます。 統合サイトの詳細情報については、ASP.NET Core での総合テスト を参照してください。
カスタム フィルターやルートを作成している場合は、コントローラーの特定のアクションに対するテストの一部としてではなく、単体テストを切り離して実行します。
コントローラーの単体テストを理解するために、次のサンプル アプリ内のコントローラーを確認してください。 Home コントローラーは、ブレーンストーミング セッションの一覧を表示し、POST 要求で新しいブレーンストーミング セッションを作成できるようにします。
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);
}
else
{
await _sessionRepository.AddAsync(new BrainstormSession()
{
DateCreated = DateTimeOffset.Now,
Name = model.SessionName
});
}
return RedirectToAction(actionName: nameof(Index));
}
}
上記のコントローラー:
- 明示的な依存関係の原則に従います。
-
依存関係の注入(DI) により
IBrainstormSessionRepositoryのインスタンスが提供されることを想定します。 -
Moq などのモック オブジェクト フレームワークを使用する、モックされた
IBrainstormSessionRepositoryサービスでテストできます。 モック オブジェクトは、プロパティとメソッドの動作のセットがあらかじめ定義された、テスト用の模擬オブジェクトです。 詳細については、「Introduction to integration tests」(統合テストの概要) を参照してください。
HTTP GET Index メソッドにはループや分岐はなく、1 つのメソッドを呼び出すだけです。 このアクションの単体テスト:
-
IBrainstormSessionRepositoryサービスをGetTestSessionsメソッドを使用してモックする。GetTestSessionsが日付とセッション名を持つ 2 つのモック ブレーンストーミング セッションを作成する。 -
Indexメソッドを実行する。 - メソッドが返す結果を検証する。
- ViewResult が返される。
-
ViewDataDictionary.Model は
StormSessionViewModelです。 -
ViewDataDictionary.Modelに格納された 2 つのブレーンストーミング セッションがある。
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync())
.ReturnsAsync(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());
}
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;
}
Home コントローラーの HTTP POST Index メソッドのテストでは、次のことが検証されます。
- ModelState.IsValid が false である場合、アクション メソッドは、適切なデータを含む 400 Bad RequestViewResult を返します。
-
ModelState.IsValidがtrueの場合:- リポジトリの
Addメソッドが呼び出される。 - 正しい引数を指定して、RedirectToActionResult が返される。
- リポジトリの
下の最初のテストに示すように、AddModelError を使用してエラーを追加することで、モデルの無効状態がテストされます。
[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.ListAsync())
.ReturnsAsync(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 が返されます。 このテストでは、無効なモデルを渡すことは試していません。 モデル バインドが実行されていないため、無効なモデルを渡すことは効果的なアプローチではありません (ただし、統合テストではモデル バインドが使用されます)。 この場合、モデル バインディングはテストされません。 これらの単体テストでは、アクション メソッドのコードだけをテストしています。
2 番目のテストでは、ModelState が有効な場合の検証を行います。
- 新しい
BrainstormSessionが追加される (リポジトリ経由)。 - メソッドは、想定されるプロパティを備えた
RedirectToActionResultを返します。
通常、呼び出されないモック化された呼び出しは無視されますが、setup 呼び出しの最後で Verifiable を呼び出すと、テスト内でモックの検証を行えるようになります。 これは mockRepo.Verify の呼び出しで実行され、予期されるメソッドが呼び出されないとテストは失敗します。
Note
このサンプルで使われている Moq ライブラリにより、検証可能な ("厳密な") モックと検証対象ではないモック ("厳密でない" モックまたはスタブとも呼ばれます) を混在させることができます。 詳しくは、Moq の「Customizing Mock Behavior」(モックの動作のカスタマイズ) をご覧ください。
サンプル アプリの SessionController は、特定のブレーンストーミング セッションに関する情報を表示します。 このコントローラーには、無効な id 値を処理するロジックが含まれています (これらのシナリオをカバーするために、次の例には 2 つの return シナリオがあります)。 最後の return ステートメントは、新しい StormSessionViewModel をビュー (Controllers/SessionController.cs) に返します。
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(actionName: nameof(Index),
controllerName: "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);
}
}
単体テストには、Session コントローラーの Index アクションにおける各 return シナリオ用のテストが 1 つ含まれます:
[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
// Arrange
var controller = new SessionController(sessionRepository: null);
// Act
var result = await controller.Index(id: null);
// Assert
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))
.ReturnsAsync((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))
.ReturnsAsync(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);
}
Ideas コントローラーに移ると、アプリは、機能を Web API として api/ideas ルートで公開します。
- ブレーンストーミング セッションに関連付けられたアイデアの一覧 (
IdeaDTO) が、ForSessionメソッドによって返される。 -
Createセッションへ新しいアイデアを追加するメソッド。
[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);
}
API の呼び出しでビジネス ドメイン エンティティを直接返すことは避けてください。 ドメイン エンティティ:
- 多くの場合、クライアントが必要とするもの以上のデータを含みます。
- アプリの内部ドメイン モデルと一般公開されている API を不必要に結合しないでください。
ドメイン エンティティとクライアントに返される型の間のマッピングは、以下のように実行できます。
- サンプル アプリで使用しているように、LINQ
Selectを使用して手動で実行します。 詳細については、「LINQ (統合言語クエリ)」を参照してください。 - 自動的に、AutoMapper などのライブラリを使用して。
次に、サンプル アプリでは、Ideas コントローラーにある Create と ForSession API メソッドの単体テストを説明します。
このサンプル アプリには、2 つの ForSession テストが含まれています。 最初のテストでは、無効なセッションの場合に ForSession が NotFoundObjectResult (HTTP Not Found) を返すかどうかを検証します。
[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((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);
}
2 番目の ForSession テストでは、有効なセッションについて ForSession がセッション案のリスト (<List<IdeaDTO>>) を返すかどうかを確認します。 これらのチェックでは、最初のアイデアを調べて、その Name プロパティが正しいことも確認します。
[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(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);
}
ModelState が無効な場合の Create メソッドの動作をテストするため、サンプル アプリでは、テストの一部としてコントローラーにモデル エラーを追加します。 単体テストでは、モデルの検証またはモデル バインドのテストを試さないでください。ModelState が無効な場合のアクション メソッドの動作だけをテストしてください:
[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);
}
Create の2番目のテストは、リポジトリが null を返すことに依存しているため、モック リポジトリは null を返すように構成されます。 テスト データベース (メモリ内またはそれ以外の場所) を作成し、この結果を返すクエリを作成する必要はありません。 サンプル コードに示すように、このテストは単一のステートメントで実行できます。
[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync((BrainstormSession)null);
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.Create(new NewIdeaModel());
// Assert
Assert.IsType<NotFoundObjectResult>(result);
}
3 番目の Create テスト (Create_ReturnsNewlyCreatedIdeaForSession) では、リポジトリの UpdateAsync メソッドが呼び出されることを検証します。 モックが Verifiable で呼び出され、検証可能なメソッドが実行されることを確認するために、モック リポジトリの Verify メソッドが呼び出されます。
UpdateAsync メソッドがデータを保存したことを確認するのは、単体テストの役割ではありません。その確認は統合テストで行えます。
[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))
.ReturnsAsync(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);
}
Test ActionResult<T>
ASP.NET Core 2.1 以降では、ActionResult<T> (ActionResult<TValue>) を使用すると、ActionResult から派生する型または特定の型を返すことができます。
サンプル アプリには、特定のセッション id に対して List<IdeaDTO> を返すメソッドが含まれています。 セッションに id が存在しない場合、コントローラーは NotFound を返します。
[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(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 result;
}
ApiIdeasControllerTestsには、ForSessionActionResult コントローラーの 2 つのテストが含まれています。
最初のテストでは、コントローラーは ActionResult エラーを返すが、存在しないセッション id の存在しないアイデアの一覧は返さないことを確認します。
-
ActionResultの型はActionResult<List<IdeaDTO>>。 - Result は NotFoundObjectResult です。
[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
// Arrange
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
var nonExistentSessionId = 999;
// Act
var result = await controller.ForSessionActionResult(nonExistentSessionId);
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}
有効なセッション id に対する 2 番目のテストでは、メソッドが以下を返すことを確認します。
-
List<IdeaDTO>型のActionResult。 -
ActionResult<T>.Value は
List<IdeaDTO>型。 - 一覧の最初の項目は、モック セッションに格納されているアイデアと一致する有効なアイデア (
GetTestSessionの呼び出しによって取得)。
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
// Arrange
int testSessionId = 123;
var mockRepo = new Mock<IBrainstormSessionRepository>();
mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
.ReturnsAsync(GetTestSession());
var controller = new IdeasController(mockRepo.Object);
// Act
var result = await controller.ForSessionActionResult(testSessionId);
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
var idea = returnValue.FirstOrDefault();
Assert.Equal("One", idea.Name);
}
このサンプル アプリには、特定のセッションで新しい Idea を作成するメソッドも含まれています。 コントローラーは以下を返します。
- BadRequest(無効なモデル用)。
- NotFound セッションが存在しない場合。
- CreatedAtAction セッションが新しいアイデアで更新されたとき。
[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([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 CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}
ApiIdeasControllerTests には、CreateActionResult の 3 つのテストが含まれます。
最初のテキストは、無効なモデルに対して BadRequest が返されることを確認します。
[Fact]
public async Task CreateActionResult_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.CreateActionResult(model: null);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}
2番目のテストでは、セッションが存在しない場合に NotFound が返されることを確認します。
[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
// Arrange
var nonExistentSessionId = 999;
string testName = "test name";
string testDescription = "test description";
var mockRepo = new Mock<IBrainstormSessionRepository>();
var controller = new IdeasController(mockRepo.Object);
var newIdea = new NewIdeaModel()
{
Description = testDescription,
Name = testName,
SessionId = nonExistentSessionId
};
// Act
var result = await controller.CreateActionResult(newIdea);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}
有効なセッション id に対しては、最後のテストで以下を確認します。
- メソッドが
ActionResult型のBrainstormSessionを返す。 -
ActionResult<T>.Result は CreatedAtActionResult です。
CreatedAtActionResultは、Locationヘッダーを含む 201 Created 応答に相当します。 -
ActionResult<T>.Value は
BrainstormSession型。 - セッション
UpdateAsync(testSession)を更新するモック 呼び出しが呼び出された。Verifiableメソッド呼び出しは、アサーション内でmockRepo.Verify()を実行することでチェックされます。 - そのセッションについて 2 つの
Ideaオブジェクトが返されます。 - 最後の項目(
UpdateAsyncへのモック呼び出しによって追加されたIdea)が、テスト中にセッションに追加されたnewIdeaと一致する。
[Fact]
public async Task CreateActionResult_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))
.ReturnsAsync(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.CreateActionResult(newIdea);
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
mockRepo.Verify();
Assert.Equal(2, returnValue.Ideas.Count());
Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}
関連するコンテンツ
- ASP.NET Core での統合テスト
- Visual Studio で単体テストを作成して実行する
- MyTested.AspNetCore.Mvc - ASP.NET Core MVC 用の Fluent テスト ライブラリ: MVC と Web API アプリをテストするための fluent インターフェイスを提供する厳密に型指定された単体テスト ライブラリ。 ("Microsoft では保守管理もサポートも行っていません。")
- JustMockLite:.NET 開発者向けのモック フレームワーク。 ("Microsoft では保守管理もサポートも行っていません。")
ASP.NET Core