이는 ASP.NET MVC 1을 사용하여 작지만 완전한 웹 애플리케이션을 빌드하는 방법을 안내하는 무료 "NerdDinner" 애플리케이션 자습서 의 12단계입니다.
12단계에서는 NerdDinner 기능을 확인하고 향후 애플리케이션을 변경하고 개선할 수 있는 자신감을 제공하는 자동화된 단위 테스트 제품군을 개발하는 방법을 보여 줍니다.
ASP.NET MVC 3을 사용하는 경우 MVC 3 또는 MVC Music Store에서 시작 자습서를 따르는 것이 좋습니다.
NerdDinner 12단계: 단위 테스트
NerdDinner 기능을 확인하고 향후 애플리케이션을 변경하고 개선할 수 있는 자신감을 제공하는 자동화된 단위 테스트 제품군을 개발해 보겠습니다.
왜 단위 테스트인가요?
어느 날에는 드라이브에서 작업 중인 애플리케이션에 대한 영감이 갑자기 번쩍입니다. 애플리케이션을 훨씬 더 효율적으로 만드는 구현할 수 있는 변경 내용이 있다는 것을 알고 있습니다. 코드를 정리하거나, 새 기능을 추가하거나, 버그를 수정하는 리팩터링일 수 있습니다.
컴퓨터에 도착했을 때 직면하는 질문은 다음과 같습니다. "이 개선을 위해 얼마나 안전합니까?" 변경하면 부작용이 있거나 중단되는 경우 어떻게 해야 할까요? 변경은 간단할 수 있으며 구현하는 데 몇 분밖에 걸리지 않지만 모든 애플리케이션 시나리오를 수동으로 테스트하는 데 몇 시간이 걸리면 어떻게 되나요? 시나리오를 다루는 것을 잊어버리고 손상된 애플리케이션이 프로덕션으로 전환되면 어떻게 해야 할까요? 이 개선을 하는 것은 정말 모든 노력의 가치가 있습니까?
자동화된 단위 테스트는 애플리케이션을 지속적으로 개선하고 작업 중인 코드를 두려워하지 않도록 하는 안전망을 제공할 수 있습니다. 기능을 빠르게 확인하는 자동화된 테스트를 통해 자신 있게 코딩할 수 있으며, 그렇지 않으면 편안하게 하지 못했을 수 있는 향상된 기능을 제공할 수 있습니다. 또한 유지 관리가 가능하고 수명이 더 긴 솔루션을 만드는 데 도움을 줍니다. 이로 인해 투자 수익률이 훨씬 높아집니다.
ASP.NET MVC Framework를 사용하면 애플리케이션 기능을 쉽고 자연스럽게 단위 테스트할 수 있습니다. 또한 테스트 우선 기반 개발을 가능하게 하는 TDD(테스트 기반 개발) 워크플로를 사용하도록 설정합니다.
NerdDinner.Tests 프로젝트
이 자습서의 시작 부분에서 NerdDinner 애플리케이션을 만들 때 애플리케이션 프로젝트와 함께 사용할 단위 테스트 프로젝트를 만들 것인지 묻는 대화 상자가 표시됩니다.
"예, 단위 테스트 프로젝트 만들기" 라디오 단추를 선택된 상태로 유지하여 "NerdDinner.Tests" 프로젝트가 솔루션에 추가되었습니다.
NerdDinner.Tests 프로젝트는 NerdDinner 애플리케이션 프로젝트 어셈블리를 참조하며, 애플리케이션 기능을 확인하는 자동화된 테스트를 쉽게 추가할 수 있습니다.
Dinner 모델 클래스에 대한 단위 테스트 만들기
모델 계층을 빌드할 때 만든 Dinner 클래스를 확인하는 몇 가지 테스트를 NerdDinner.Tests 프로젝트에 추가해 보겠습니다.
먼저 테스트 프로젝트 내에 모델 관련 테스트를 배치할 "모델"이라는 새 폴더를 만듭니다. 그런 다음 폴더를 마우스 오른쪽 단추로 클릭하고 추가->새 테스트 메뉴 명령을 선택합니다. 그러면 "새 테스트 추가" 대화 상자가 표시됩니다.
"단위 테스트"를 만들고 이름을 "DinnerTest.cs"로 지정합니다.
"확인" 단추를 클릭하면 Visual Studio에서 프로젝트에 DinnerTest.cs 파일을 추가하고 엽니다.
기본 Visual Studio 단위 테스트 템플릿에는 약간 지저분한 보일러 플레이트 코드가 많이 있습니다. 아래 코드를 포함하도록 클린 해 보겠습니다.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NerdDinner.Models;
namespace NerdDinner.Tests.Models {
[TestClass]
public class DinnerTest {
}
}
위의 DinnerTest 클래스의 [TestClass] 특성은 테스트뿐만 아니라 선택적 테스트 초기화 및 해체 코드를 포함하는 클래스로 식별합니다. [TestMethod] 특성이 있는 공용 메서드를 추가하여 테스트를 정의할 수 있습니다.
다음은 Dinner 클래스를 연습하는 두 테스트 중 첫 번째 테스트입니다. 첫 번째 테스트는 모든 속성이 올바르게 설정되지 않고 새 Dinner가 만들어지면 Dinner가 유효하지 않은지 확인합니다. 두 번째 테스트는 Dinner에 유효한 값으로 설정된 모든 속성이 있는 경우 Dinner가 유효한지 확인합니다.
[TestClass]
public class DinnerTest {
[TestMethod]
public void Dinner_Should_Not_Be_Valid_When_Some_Properties_Incorrect() {
//Arrange
Dinner dinner = new Dinner() {
Title = "Test title",
Country = "USA",
ContactPhone = "BOGUS"
};
// Act
bool isValid = dinner.IsValid;
//Assert
Assert.IsFalse(isValid);
}
[TestMethod]
public void Dinner_Should_Be_Valid_When_All_Properties_Correct() {
//Arrange
Dinner dinner = new Dinner {
Title = "Test title",
Description = "Some description",
EventDate = DateTime.Now,
HostedBy = "ScottGu",
Address = "One Microsoft Way",
Country = "USA",
ContactPhone = "425-703-8072",
Latitude = 93,
Longitude = -92,
};
// Act
bool isValid = dinner.IsValid;
//Assert
Assert.IsTrue(isValid);
}
}
위에서 테스트 이름이 매우 명시적(그리고 다소 자세한 정보 표시)임을 알 수 있습니다. 우리는 수백 또는 수천 개의 작은 테스트를 만들 수 있기 때문에 이 작업을 수행하고 있으며 각 테스트 실행기의 의도와 동작을 쉽게 확인할 수 있기를 원합니다(특히 테스트 실행기에서 실패 목록을 살펴볼 때). 테스트 이름은 테스트하는 기능의 이름을 따서 지정해야 합니다. 위에서는 "Noun_Should_Verb" 명명 패턴을 사용합니다.
"정렬, 작업, 어설션"을 의미하는 "AAA" 테스트 패턴을 사용하여 테스트를 구조화하고 있습니다.
- 정렬: 테스트 중인 단위 설정
- 작업: 테스트 중인 단위를 연습하고 결과를 캡처합니다.
- 어설션: 동작 확인
테스트를 작성할 때 개별 테스트가 너무 많이 수행되는 것을 방지하려고 합니다. 대신 각 테스트는 단일 개념만 확인해야 합니다(오류 원인을 훨씬 쉽게 파악할 수 있도록 합니다). 좋은 지침은 각 테스트에 대해 단일 assert 문만 시도하고 사용하는 것입니다. 테스트 메서드에 어설션 문이 두 개 이상 있는 경우 모두 동일한 개념을 테스트하는 데 사용되고 있는지 확인합니다. 의심스러운 경우 다른 테스트를 합니다.
테스트 실행 중
Visual Studio 2008 Professional(이상 버전)에는 IDE 내에서 Visual Studio 단위 테스트 프로젝트를 실행하는 데 사용할 수 있는 기본 제공 테스트 실행기가 포함되어 있습니다. 솔루션 메뉴 명령(또는 Ctrl R, A를 입력) 에서 테스트->실행->모든 테스트를 선택하여 모든 단위 테스트를 실행할 수 있습니다. 또는 특정 테스트 클래스 또는 테스트 메서드 내에 커서를 배치하고 현재 상황에 맞는 메뉴 명령(또는 Ctrl R, T 입력)에서 Test-Run-Tests>>를 사용하여 단위 테스트의 하위 집합을 실행할 수 있습니다.
DinnerTest 클래스 내에 커서를 놓고 "Ctrl R, T"를 입력하여 방금 정의한 두 테스트를 실행해 보겠습니다. 이렇게 하면 Visual Studio 내에 "테스트 결과" 창이 표시되고 테스트 실행 결과가 그 안에 나열됩니다.
참고: VS 테스트 결과 창에는 기본적으로 클래스 이름 열이 표시되지 않습니다. 테스트 결과 창 내에서 마우스 오른쪽 단추를 클릭하고 열 추가/제거 메뉴 명령을 사용하여 추가할 수 있습니다.
두 테스트는 실행하는 데 1초 정도밖에 걸리지 않았으며, 두 테스트가 모두 통과된 것을 볼 수 있습니다. 이제 특정 규칙 유효성 검사를 확인하는 추가 테스트를 만들어 보강할 수 있을 뿐만 아니라 Dinner 클래스에 추가한 두 가지 도우미 메서드인 IsUserHost() 및 IsUserRegistered()를 다룰 수 있습니다. Dinner 클래스에 대해 이러한 모든 테스트를 준비하면 향후 새 비즈니스 규칙 및 유효성 검사를 훨씬 더 쉽고 안전하게 추가할 수 있습니다. Dinner에 새 규칙 논리를 추가한 다음 몇 초 내에 이전 논리 기능이 손상되지 않았는지 확인할 수 있습니다.
설명이 포함된 테스트 이름을 사용하면 각 테스트가 확인하는 내용을 쉽게 이해할 수 있습니다. 도구 옵션> 메뉴 명령을 사용하여 테스트 도구 테스트> 실행 구성 화면을 열고 "실패하거나 결정적이지 않은 단위 테스트 결과를 두 번 클릭하면 테스트의 실패 지점이 표시됩니다." 확인란을 확인하는 것이 좋습니다. 이렇게 하면 테스트 결과 창에서 실패를 두 번 클릭하고 어설션 실패로 즉시 이동할 수 있습니다.
DinnersController 단위 테스트 만들기
이제 DinnersController 기능을 확인하는 몇 가지 단위 테스트를 만들어 보겠습니다. 먼저 테스트 프로젝트 내의 "컨트롤러" 폴더를 마우스 오른쪽 단추로 클릭한 다음 추가->새 테스트 메뉴 명령을 선택합니다. "단위 테스트"를 만들고 이름을 "DinnersControllerTest.cs"로 지정합니다.
DinnersController에서 Details() 작업 메서드를 확인하는 두 가지 테스트 메서드를 만듭니다. 첫 번째는 기존 Dinner가 요청될 때 뷰가 반환되는지 확인합니다. 두 번째는 존재하지 않는 Dinner가 요청될 때 "NotFound" 보기가 반환되는지 확인합니다.
[TestClass]
public class DinnersControllerTest {
[TestMethod]
public void DetailsAction_Should_Return_View_For_ExistingDinner() {
// Arrange
var controller = new DinnersController();
// Act
var result = controller.Details(1) as ViewResult;
// Assert
Assert.IsNotNull(result, "Expected View");
}
[TestMethod]
public void DetailsAction_Should_Return_NotFoundView_For_BogusDinner() {
// Arrange
var controller = new DinnersController();
// Act
var result = controller.Details(999) as ViewResult;
// Assert
Assert.AreEqual("NotFound", result.ViewName);
}
}
위의 코드는 클린 컴파일합니다. 하지만 테스트를 실행하면 둘 다 실패합니다.
오류 메시지를 살펴보면 DinnersRepository 클래스가 데이터베이스에 연결할 수 없기 때문에 테스트에 실패한 이유가 표시됩니다. NerdDinner 애플리케이션은 NerdDinner 애플리케이션 프로젝트의 \App_Data 디렉터리에 있는 로컬 SQL Server Express 파일에 대한 연결 문자열을 사용합니다. NerdDinner.Tests 프로젝트는 다른 디렉터리에서 컴파일되고 실행된 다음 애플리케이션 프로젝트에서 실행되므로 연결 문자열의 상대 경로 위치가 잘못되었습니다.
SQL Express 데이터베이스 파일을 테스트 프로젝트에 복사한 다음 테스트 프로젝트의 App.config 적절한 테스트 연결 문자열을 추가하여 이 문제를 해결할 수 있습니다. 이렇게 하면 위의 테스트가 차단 해제되고 실행됩니다.
하지만 실제 데이터베이스를 사용하여 코드를 단위 테스트하면 여러 가지 문제가 발생합니다. 특히 다음에 대해 주의하세요.
- 단위 테스트의 실행 시간이 크게 느려집니다. 테스트를 실행하는 데 시간이 오래 걸릴수록 테스트를 자주 실행할 가능성이 줄어듭니다. 단위 테스트를 몇 초 만에 실행할 수 있고 프로젝트를 컴파일하는 것처럼 자연스럽게 실행되도록 하는 것이 가장 좋습니다.
- 테스트 내에서 설정 및 정리 논리가 복잡합니다. 각 단위 테스트를 격리하고 다른 테스트와 독립적이어야 합니다(부작용이나 종속성 없음). 실제 데이터베이스에 대해 작업할 때 상태를 염두에 두고 테스트 간에 다시 설정해야 합니다.
이러한 문제를 해결하고 테스트와 함께 실제 데이터베이스를 사용할 필요가 없도록 하는 "종속성 주입"이라는 디자인 패턴을 살펴보겠습니다.
종속성 주입
현재 DinnersController는 DinnerRepository 클래스와 긴밀하게 "결합"되어 있습니다. "결합"은 클래스가 작동하기 위해 다른 클래스에 명시적으로 의존하는 상황을 나타냅니다.
public class DinnersController : Controller {
DinnerRepository dinnerRepository = new DinnerRepository();
//
// GET: /Dinners/Details/5
public ActionResult Details(int id) {
Dinner dinner = dinnerRepository.FindDinner(id);
if (dinner == null)
return View("NotFound");
return View(dinner);
}
DinnerRepository 클래스는 데이터베이스에 액세스해야 하므로 DinnersController 클래스가 DinnerRepository에 대해 가지고 있는 긴밀하게 결합된 종속성은 결국 DinnersController 작업 메서드를 테스트하기 위해 데이터베이스가 있어야 합니다.
"종속성 주입"이라는 디자인 패턴을 사용하여 이를 해결할 수 있습니다. 이는 종속성(예: 데이터 액세스를 제공하는 리포지토리 클래스)이 더 이상 이를 사용하는 클래스 내에서 암시적으로 만들어지지 않는 접근 방식입니다. 대신 생성자 인수를 사용하여 종속성을 사용하는 클래스에 명시적으로 전달할 수 있습니다. 인터페이스를 사용하여 종속성을 정의하는 경우 단위 테스트 시나리오에 대한 "가짜" 종속성 구현을 유연하게 전달할 수 있습니다. 이렇게 하면 데이터베이스에 실제로 액세스할 필요가 없는 테스트별 종속성 구현을 만들 수 있습니다.
이 동작을 확인하려면 DinnersController를 사용하여 종속성 주입을 구현해 보겠습니다.
IDinnerRepository 인터페이스 추출
첫 번째 단계는 컨트롤러가 Dinners를 검색하고 업데이트하는 데 필요한 리포지토리 계약을 캡슐화하는 새 IDinnerRepository 인터페이스를 만드는 것입니다.
\Models 폴더를 마우스 오른쪽 단추로 클릭한 다음 추가->새 항목 메뉴 명령을 선택하고 IDinnerRepository.cs 라는 새 인터페이스를 만들어 이 인터페이스 계약을 수동으로 정의할 수 있습니다.
또는 리팩터링 도구를 기본 제공 Visual Studio Professional(이상 버전)를 사용하여 기존 DinnerRepository 클래스에서 자동으로 인터페이스를 추출하고 만들 수 있습니다. VS를 사용하여 이 인터페이스를 추출하려면 DinnerRepository 클래스의 텍스트 편집기에서 커서를 놓고 마우스 오른쪽 단추를 클릭하고 리팩터링 인터페이스> 추출 메뉴 명령을 선택하기만 하면 됩니다.
그러면 "인터페이스 추출" 대화 상자가 시작되고 만들 인터페이스의 이름을 묻는 메시지가 표시됩니다. 기본값은 IDinnerRepository이며, 인터페이스에 추가할 기존 DinnerRepository 클래스의 모든 공용 메서드를 자동으로 선택합니다.
"확인" 단추를 클릭하면 Visual Studio에서 애플리케이션에 새 IDinnerRepository 인터페이스를 추가합니다.
public interface IDinnerRepository {
IQueryable<Dinner> FindAllDinners();
IQueryable<Dinner> FindByLocation(float latitude, float longitude);
IQueryable<Dinner> FindUpcomingDinners();
Dinner GetDinner(int id);
void Add(Dinner dinner);
void Delete(Dinner dinner);
void Save();
}
또한 기존 DinnerRepository 클래스는 인터페이스를 구현할 수 있도록 업데이트됩니다.
public class DinnerRepository : IDinnerRepository {
...
}
생성자 주입을 지원하도록 DinnersController 업데이트
이제 새 인터페이스를 사용하도록 DinnersController 클래스를 업데이트합니다.
현재 DinnersController는 "dinnerRepository" 필드가 항상 DinnerRepository 클래스가 되도록 하드 코딩되어 있습니다.
public class DinnersController : Controller {
DinnerRepository dinnerRepository = new DinnerRepository();
...
}
"dinnerRepository" 필드가 DinnerRepository 대신 IDinnerRepository 형식이 되도록 변경합니다. 그런 다음 두 개의 공용 DinnersController 생성자를 추가합니다. 생성자 중 하나를 사용하면 IDinnerRepository를 인수로 전달할 수 있습니다. 다른 하나는 기존 DinnerRepository 구현을 사용하는 기본 생성자입니다.
public class DinnersController : Controller {
IDinnerRepository dinnerRepository;
public DinnersController()
: this(new DinnerRepository()) {
}
public DinnersController(IDinnerRepository repository) {
dinnerRepository = repository;
}
...
}
ASP.NET MVC는 기본적으로 기본 생성자를 사용하여 컨트롤러 클래스를 만들기 때문에 런타임 시 DinnersController는 DinnerRepository 클래스를 계속 사용하여 데이터 액세스를 수행합니다.
하지만 이제 매개 변수 생성자를 사용하여 "가짜" 저녁 식사 리포지토리 구현을 전달하도록 단위 테스트를 업데이트할 수 있습니다. 이 "가짜" 저녁 식사 리포지토리는 실제 데이터베이스에 액세스할 필요가 없으며 대신 메모리 내 샘플 데이터를 사용합니다.
FakeDinnerRepository 클래스 만들기
FakeDinnerRepository 클래스를 만들어 보겠습니다.
먼저 NerdDinner.Tests 프로젝트 내에 "Fakes" 디렉터리를 만든 다음 새 FakeDinnerRepository 클래스를 추가합니다(폴더를 마우스 오른쪽 단추로 클릭하고 추가->새 클래스 선택).
FakeDinnerRepository 클래스가 IDinnerRepository 인터페이스를 구현할 수 있도록 코드를 업데이트합니다. 그런 다음 마우스 오른쪽 단추로 클릭하고 "IDinnerRepository 인터페이스 구현" 상황에 맞는 메뉴 명령을 선택할 수 있습니다.
이렇게 하면 Visual Studio에서 기본 "스텁 아웃" 구현을 사용하여 모든 IDinnerRepository 인터페이스 멤버를 FakeDinnerRepository 클래스에 자동으로 추가합니다.
public class FakeDinnerRepository : IDinnerRepository {
public IQueryable<Dinner> FindAllDinners() {
throw new NotImplementedException();
}
public IQueryable<Dinner> FindByLocation(float lat, float long){
throw new NotImplementedException();
}
public IQueryable<Dinner> FindUpcomingDinners() {
throw new NotImplementedException();
}
public Dinner GetDinner(int id) {
throw new NotImplementedException();
}
public void Add(Dinner dinner) {
throw new NotImplementedException();
}
public void Delete(Dinner dinner) {
throw new NotImplementedException();
}
public void Save() {
throw new NotImplementedException();
}
}
그런 다음, FakeDinnerRepository 구현을 업데이트하여 생성자 인수로 전달된 메모리 내 List<Dinner> 컬렉션에서 작동할 수 있습니다.
public class FakeDinnerRepository : IDinnerRepository {
private List<Dinner> dinnerList;
public FakeDinnerRepository(List<Dinner> dinners) {
dinnerList = dinners;
}
public IQueryable<Dinner> FindAllDinners() {
return dinnerList.AsQueryable();
}
public IQueryable<Dinner> FindUpcomingDinners() {
return (from dinner in dinnerList
where dinner.EventDate > DateTime.Now
select dinner).AsQueryable();
}
public IQueryable<Dinner> FindByLocation(float lat, float lon) {
return (from dinner in dinnerList
where dinner.Latitude == lat && dinner.Longitude == lon
select dinner).AsQueryable();
}
public Dinner GetDinner(int id) {
return dinnerList.SingleOrDefault(d => d.DinnerID == id);
}
public void Add(Dinner dinner) {
dinnerList.Add(dinner);
}
public void Delete(Dinner dinner) {
dinnerList.Remove(dinner);
}
public void Save() {
foreach (Dinner dinner in dinnerList) {
if (!dinner.IsValid)
throw new ApplicationException("Rule violations");
}
}
}
이제 데이터베이스가 필요하지 않고 Dinner 개체의 메모리 내 목록을 대신 사용할 수 있는 가짜 IDinnerRepository 구현이 있습니다.
단위 테스트와 함께 FakeDinnerRepository 사용
데이터베이스를 사용할 수 없어 이전에 실패한 DinnersController 단위 테스트로 돌아가 보겠습니다. 아래 코드를 사용하여 메모리 내 저녁 식사 데이터 샘플로 채워진 FakeDinnerRepository를 사용하도록 테스트 메서드를 DinnersController로 업데이트할 수 있습니다.
[TestClass]
public class DinnersControllerTest {
List<Dinner> CreateTestDinners() {
List<Dinner> dinners = new List<Dinner>();
for (int i = 0; i < 101; i++) {
Dinner sampleDinner = new Dinner() {
DinnerID = i,
Title = "Sample Dinner",
HostedBy = "SomeUser",
Address = "Some Address",
Country = "USA",
ContactPhone = "425-555-1212",
Description = "Some description",
EventDate = DateTime.Now.AddDays(i),
Latitude = 99,
Longitude = -99
};
dinners.Add(sampleDinner);
}
return dinners;
}
DinnersController CreateDinnersController() {
var repository = new FakeDinnerRepository(CreateTestDinners());
return new DinnersController(repository);
}
[TestMethod]
public void DetailsAction_Should_Return_View_For_Dinner() {
// Arrange
var controller = CreateDinnersController();
// Act
var result = controller.Details(1);
// Assert
Assert.IsInstanceOfType(result, typeof(ViewResult));
}
[TestMethod]
public void DetailsAction_Should_Return_NotFoundView_For_BogusDinner() {
// Arrange
var controller = CreateDinnersController();
// Act
var result = controller.Details(999) as ViewResult;
// Assert
Assert.AreEqual("NotFound", result.ViewName);
}
}
그리고 이제 이러한 테스트를 실행할 때 둘 다 통과합니다.
무엇보다도 실행하는 데 1초 정도밖에 걸리지 않으며 복잡한 설정/정리 논리가 필요하지 않습니다. 이제 실제 데이터베이스에 연결하지 않고도 DinnersController 작업 메서드 코드(목록, 페이징, 세부 정보, 만들기, 업데이트 및 삭제 포함)를 모두 단위 테스트할 수 있습니다.
측면 항목: 종속성 주입 프레임워크 |
---|
수동 종속성 주입(위와 같이)은 정상적으로 작동하지만 애플리케이션의 종속성 및 구성 요소 수가 증가함에 따라 유지 관리하기가 더 어려워집니다. .NET에 대한 몇 가지 종속성 주입 프레임워크가 존재하므로 더 많은 종속성 관리 유연성을 제공할 수 있습니다. 이러한 프레임워크는 "IoC(Inversion of Control)" 컨테이너라고도 하며, 런타임에 종속성을 지정하고 개체에 전달하기 위한 추가 수준의 구성 지원을 가능하게 하는 메커니즘을 제공합니다(생성자 삽입을 사용하는 경우가 가장 많습니다). .NET에서 가장 인기 있는 OSS 종속성 주입/IOC 프레임워크 중 일부는 AutoFac, Ninject, Spring.NET, StructureMap 및 Windsor입니다. ASP.NET MVC는 개발자가 컨트롤러의 확인 및 인스턴스화에 참여할 수 있도록 하는 확장성 API를 노출하며, 이를 통해 종속성 주입/IoC 프레임워크를 이 프로세스 내에서 깔끔하게 통합할 수 있습니다. DI/IOC 프레임워크를 사용하면 DinnersController에서 기본 생성자를 제거할 수도 있습니다. 그러면 DinnerSController와 DinnerRepository 간의 결합이 완전히 제거됩니다. NerdDinner 애플리케이션과 함께 종속성 주입/IOC 프레임워크를 사용하지 않습니다. 그러나 NerdDinner 코드 베이스와 기능이 증가하면 미래를 위해 고려할 수 있는 것입니다. |
편집 작업 단위 테스트 만들기
이제 DinnersController의 편집 기능을 확인하는 몇 가지 단위 테스트를 만들어 보겠습니다. 먼저 편집 작업의 HTTP-GET 버전을 테스트합니다.
//
// GET: /Dinners/Edit/5
[Authorize]
public ActionResult Edit(int id) {
Dinner dinner = dinnerRepository.GetDinner(id);
if (!dinner.IsHostedBy(User.Identity.Name))
return View("InvalidOwner");
return View(new DinnerFormViewModel(dinner));
}
유효한 저녁 식사가 요청될 때 DinnerFormViewModel 개체에서 지원되는 View가 다시 렌더링되는지 확인하는 테스트를 만듭니다.
[TestMethod]
public void EditAction_Should_Return_View_For_ValidDinner() {
// Arrange
var controller = CreateDinnersController();
// Act
var result = controller.Edit(1) as ViewResult;
// Assert
Assert.IsInstanceOfType(result.ViewData.Model, typeof(DinnerFormViewModel));
}
하지만 테스트를 실행하면 Edit 메서드가 User.Identity.Name 속성에 액세스하여 Dinner.IsHostedBy() 검사 수행할 때 null 참조 예외가 throw되므로 실패합니다.
Controller 기본 클래스의 User 개체는 로그인한 사용자에 대한 세부 정보를 캡슐화하며 런타임에 컨트롤러를 만들 때 ASP.NET MVC로 채워집니다. 웹 서버 환경 외부에서 DinnersController를 테스트하기 때문에 User 개체가 설정되지 않았습니다(따라서 null 참조 예외).
User.Identity.Name 속성 모의
모의 프레임워크를 사용하면 테스트를 지원하는 종속 개체의 가짜 버전을 동적으로 만들 수 있으므로 테스트가 더 쉬워질 수 있습니다. 예를 들어 편집 작업 테스트에서 모의 프레임워크를 사용하여 DinnersController가 시뮬레이션된 사용자 이름을 조회하는 데 사용할 수 있는 User 개체를 동적으로 만들 수 있습니다. 이렇게 하면 테스트를 실행할 때 null 참조가 throw되지 않습니다.
ASP.NET MVC와 함께 사용할 수 있는 많은 .NET 모의 프레임워크가 있습니다(여기에서 http://www.mockframeworks.com/목록을 볼 수 있음).
다운로드되면 NerdDinner.Tests 프로젝트의 참조를 Moq.dll 어셈블리에 추가합니다.
그런 다음, 사용자 이름을 매개 변수로 사용하고 DinnersController instance User.Identity.Name 속성을 "모의"하는 테스트 클래스에 "CreateDinnersControllerAs(username)" 도우미 메서드를 추가합니다.
DinnersController CreateDinnersControllerAs(string userName) {
var mock = new Mock<ControllerContext>();
mock.SetupGet(p => p.HttpContext.User.Identity.Name).Returns(userName);
mock.SetupGet(p => p.HttpContext.Request.IsAuthenticated).Returns(true);
var controller = CreateDinnersController();
controller.ControllerContext = mock.Object;
return controller;
}
위에서는 Moq를 사용하여 ControllerContext 개체를 가짜로 만드는 Mock 개체를 만듭니다(MVC가 사용자, 요청, 응답 및 세션과 같은 런타임 개체를 노출하기 위해 컨트롤러 클래스에 전달하는 ASP.NET). Mock에서 "SetupGet" 메서드를 호출하여 ControllerContext의 HttpContext.User.Identity.Name 속성이 도우미 메서드에 전달한 사용자 이름 문자열을 반환해야 함을 나타냅니다.
여러 ControllerContext 속성 및 메서드를 모의할 수 있습니다. 이를 설명하기 위해 Request.IsAuthenticated 속성에 대한 SetupGet() 호출도 추가했습니다(아래 테스트에는 실제로 필요하지 않지만 요청 속성을 모의하는 방법을 설명하는 데 도움이 됨). 완료되면 ControllerContext 모의 instance 도우미 메서드가 반환하는 DinnersController에 할당합니다.
이제 이 도우미 메서드를 사용하는 단위 테스트를 작성하여 다른 사용자와 관련된 시나리오 편집을 테스트할 수 있습니다.
[TestMethod]
public void EditAction_Should_Return_EditView_When_ValidOwner() {
// Arrange
var controller = CreateDinnersControllerAs("SomeUser");
// Act
var result = controller.Edit(1) as ViewResult;
// Assert
Assert.IsInstanceOfType(result.ViewData.Model, typeof(DinnerFormViewModel));
}
[TestMethod]
public void EditAction_Should_Return_InvalidOwnerView_When_InvalidOwner() {
// Arrange
var controller = CreateDinnersControllerAs("NotOwnerUser");
// Act
var result = controller.Edit(1) as ViewResult;
// Assert
Assert.AreEqual(result.ViewName, "InvalidOwner");
}
이제 테스트를 실행할 때 통과합니다.
UpdateModel() 시나리오 테스트
편집 작업의 HTTP-GET 버전을 다루는 테스트를 만들었습니다. 이제 편집 작업의 HTTP-POST 버전을 확인하는 몇 가지 테스트를 만들어 보겠습니다.
//
// POST: /Dinners/Edit/5
[AcceptVerbs(HttpVerbs.Post), Authorize]
public ActionResult Edit (int id, FormCollection collection) {
Dinner dinner = dinnerRepository.GetDinner(id);
if (!dinner.IsHostedBy(User.Identity.Name))
return View("InvalidOwner");
try {
UpdateModel(dinner);
dinnerRepository.Save();
return RedirectToAction("Details", new { id=dinner.DinnerID });
}
catch {
ModelState.AddModelErrors(dinner.GetRuleViolations());
return View(new DinnerFormViewModel(dinner));
}
}
이 작업 메서드를 지원하는 흥미로운 새 테스트 시나리오는 Controller 기본 클래스에서 UpdateModel() 도우미 메서드를 사용하는 것입니다. 이 도우미 메서드를 사용하여 form-post 값을 Dinner 개체 instance 바인딩합니다.
다음은 사용할 UpdateModel() 도우미 메서드에 대해 게시된 양식 값을 제공하는 방법을 보여 주는 두 가지 테스트입니다. FormCollection 개체를 만들고 채운 다음 컨트롤러의 "ValueProvider" 속성에 할당하여 이 작업을 수행합니다.
첫 번째 테스트는 성공적으로 저장 시 브라우저가 세부 정보 작업으로 리디렉션되었는지 확인합니다. 두 번째 테스트는 잘못된 입력이 게시될 때 작업이 오류 메시지와 함께 편집 보기를 다시 표시한다는 것을 확인합니다.
[TestMethod]
public void EditAction_Should_Redirect_When_Update_Successful() {
// Arrange
var controller = CreateDinnersControllerAs("SomeUser");
var formValues = new FormCollection() {
{ "Title", "Another value" },
{ "Description", "Another description" }
};
controller.ValueProvider = formValues.ToValueProvider();
// Act
var result = controller.Edit(1, formValues) as RedirectToRouteResult;
// Assert
Assert.AreEqual("Details", result.RouteValues["Action"]);
}
[TestMethod]
public void EditAction_Should_Redisplay_With_Errors_When_Update_Fails() {
// Arrange
var controller = CreateDinnersControllerAs("SomeUser");
var formValues = new FormCollection() {
{ "EventDate", "Bogus date value!!!"}
};
controller.ValueProvider = formValues.ToValueProvider();
// Act
var result = controller.Edit(1, formValues) as ViewResult;
// Assert
Assert.IsNotNull(result, "Expected redisplay of view");
Assert.IsTrue(result.ViewData.ModelState.Count > 0, "Expected errors");
}
테스트 Wrap-Up
단위 테스트 컨트롤러 클래스와 관련된 핵심 개념을 다루었습니다. 이러한 기술을 사용하여 애플리케이션의 동작을 확인하는 수백 개의 간단한 테스트를 쉽게 만들 수 있습니다.
컨트롤러 및 모델 테스트에는 실제 데이터베이스가 필요하지 않으므로 매우 빠르고 실행하기 쉽습니다. 몇 초 안에 수백 개의 자동화된 테스트를 실행하고 변경 내용으로 인해 문제가 발생했는지에 대한 피드백을 즉시 받을 수 있습니다. 이렇게 하면 애플리케이션을 지속적으로 개선하고, 리팩터링하고, 구체화할 수 있습니다.
이 챕터의 마지막 항목으로 테스트를 다루었지만, 테스트는 개발 프로세스가 끝날 때 해야 할 일이기 때문은 아닙니다. 반대로 개발 프로세스에서 가능한 한 빨리 자동화된 테스트를 작성해야 합니다. 이렇게 하면 개발 시 즉각적인 피드백을 받고, 애플리케이션의 사용 사례 시나리오에 대해 신중하게 생각하고, 클린 계층화 및 결합을 염두에 두고 애플리케이션을 디자인하도록 안내할 수 있습니다.
이 책의 뒷부분에서는 TDD(시험 개발)와 ASP.NET MVC와 함께 사용하는 방법에 대해 설명합니다. TDD는 결과 코드가 충족할 테스트를 먼저 작성하는 반복적인 코딩 사례입니다. TDD를 사용하면 구현하려는 기능을 확인하는 테스트를 만들어 각 기능을 시작합니다. 먼저 단위 테스트를 작성하면 기능과 작동 방식을 명확하게 이해할 수 있습니다. 테스트가 작성된 후에만(그리고 테스트가 실패했음을 확인함) 테스트가 확인하는 실제 기능을 구현합니다. 기능이 작동하는 방식의 사용 사례에 대해 생각하는 데 이미 시간을 보냈기 때문에 요구 사항과 이를 구현하는 가장 좋은 방법을 더 잘 이해할 수 있습니다. 구현이 완료되면 테스트를 다시 실행하고 기능이 올바르게 작동하는지 여부에 대한 즉각적인 피드백을 받을 수 있습니다. 10장에서 TDD에 대해 자세히 살펴보겠습니다.
다음 단계
일부 최종 마무리 주석입니다.