단위 Test는 Application의 개별적 요소를 독립적으로 분리하여 Test를 수행하는 것으로 Application의 오류를 최소화하는데 중점을 둡니다. ASP.NET Core는 단위 test를 쉽게 만들 수 있도록 설계되었으며 다양한 단위 test framework를 지원합니다.
1. Project 준비
단위 test를 사용해 볼 Project를 생성하기 위해 PowerShell을 열고 적당한 위치에서 아래 명령을 내려줍니다.
dotnet new globaljson --sdk-version 8.0.202 --output UnitTest/UnitTest dotnet new web --no-https --output UnitTest/UnitTest --framework net8.0 dotnet new sln -o UnitTest dotnet sln UnitTest add UnitTest/UnitTest |
위 명령을 통해 web template을 사용하여 UnitTest라는 Project를 생성하며 이때 ASP.NET Core application을 위한 최소한의 구성만을 사용합니다.
(1) MVC Framework 사용하기
ASP.NET Core는 다양한 Application framework를 지원하지만 단위 Test를 위한 가장 적합한 것으로 MVC framework를 사용하고자 합니다. 이를 위해 Program.cs를 다음과 같이 변경합니다.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
var app = builder.Build();
app.MapDefaultControllerRoute();
app.Run();
(2) Application component 생성
MVC Framework설정을 완료하고 나면 이제 단위 test 실행을 위한 Application Component를 추가합니다.
● Data Model
Application에서 사용할 Model을 추가하기 위해 Project에 Models folder를 생성하고 여기에 Products.cs이름의 file을 아래와 같이 추가합니다.
namespace UnitTest.Models;
public class Product
{
public string Name { get; set; } = string.Empty;
public int Price { get; set; } = 0;
public static Product[] GetProducts()
{
Product CPU = new Product { Name = "CPU i7", Price = 250000 };
Product Memory = new Product { Name = "Memory DDR5", Price = 120000 };
return new Product[] { CPU, Memory };
}
}
해당 Model은 Name과 Price라는 속성과 함께 전체 Product개체를 반환하는 GetProducts라는 method를 정의하고 있습니다.
● Controller와 View
다음으로 MVC의 근간이 되는 Controller와 View를 추가할 것입니다. Project에 Controllers라는 folder를 생성하고 여기에 HomeController.cs라는 file을 아래와 같이 추가합니다.
using Microsoft.AspNetCore.Mvc;
using UnitTest.Models;
namespace UNITTEST.Controllers;
public class HomeController: Controller
{
public ViewResult Index()
{
return View(Product.GetProducts());
}
}
예제의 Index method는 ASP.NET Core가 기본 view를 render 하되 Product의 GetProducts method를 통해 가져온 Product 개체를 함께 제공하고 있습니다. 이제 위에서 만든 Action method의 view를 만들기 위해 Project에 Views folder를 생성하고 그 안에 다시 Home folder를 생성한 뒤 여기에 Index.cshtml 이름의 file을 아래와 같이 추가합니다.
@using UnitTest.Models
@model IEnumerable<Product>
@{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<title>UNIT TEST</title>
</head>
<body>
<ul>
@foreach (Product p in Model) {
<li>Name: @p.Name, Price: @p.Price</li>
}
</ul>
</body>
</html>
(3) 예제 Application 실행하기
예제 Application을 실행하기 위해 PowerShell에서 다음과 같이 명령을 내려줍니다.
dotnet watch |
그러면 web browser는 아래와 같은 결과를 표시할 것입니다.
2. 단위 test Project 만들기
보통 단위 test는 Solution에서 단위 test를 위한 별도의 Project로 추가합니다. 이렇게 함으로써 실제 Application을 배포할 때 단위 test를 제외하고 배포할 수 있습니다.
.NET Core SDK는 아래 3가지 종류의 단위 test 도구를 사용할 수 있는 Project의 template를 포함하고 있습니다.
mstest | MS Test framework를 사용하기 위한 template입니다. |
nunit | NUnit framework를 사용하기 위한 template입니다. |
xunit | XUnit framework를 사용하기 위한 template입니다. |
이들 framework는 대체로 동일한 목적의 기능들을 가지고 있으며 단지 이들이 구현되는 방식만 다를 뿐입니다. 예제에서는 XUnit를 사용할 것입니다.
일반적으로 단위 test의 Project이름은 'Application명.Tests'로 하기 때문에 예제에서도 이에 따라 UnitTest.Tests이름의 XUnit test Project를 생성하고 이를 Solution file에 추가합니다. 그런 다음 단위 test를 UnitTest project에 정의된 class에 적용할 수 있도록 Project 간 참조를 생성합니다.
dotnet new xunit -o UnitTest.Tests --framework net8.0 dotnet sln add UnitTest.Tests dotnet add UnitTest.Tests reference UnitTest |
3. 단위 Test 작성 및 실행
위 절차까지 완료하였으면 이제 필요한 단위 test를 작성할 수 있습니다. 이를 시작하기 위해 Test project의 UnitTest1.cs file을 ProductTests.cs로 변경하고 다음과 같이 작성합니다.
using UnitTest.Models;
namespace UnitTest.Tests;
public class ProductTests
{
[Fact]
public void CanChangeProjectName()
{
//Arrange
Product p = new Product { Name = "SSD", Price = 55000 };
//Act
p.Name = "HDD";
//Assert
Assert.Equal("HDD", p.Name);
}
[Fact]
public void CanChangeProjectPrice()
{
//Arrange
Product p = new Product { Name = "SSD", Price = 55000 };
//Act
p.Price = 70000;
//Assert
Assert.Equal(60000, p.Price);
}
}
ProductTests class에는 2개의 단위 test를 정의했으며 각각의 test는 UnitTest Project의 Product model을 대상으로 test를 수행합니다. Test Project는 다수의 Class를 포함할 수 있으며 각각의 Class는 다수의 단위 test를 포함할 수 있습니다.
일반적으로 test method는 어떤 test를 수행할 것인지에 대한 이름을 가지며 Class는 무엇을 test하는가에 해당하는 이름을 가지게 함으로써 Test Project에서 test를 쉽게 구성할 수 있도록 합니다. 예제에서 ProdutTests이름은 해당 Class가 Product Class에 대한 test를 포함하고 있음을 나타내며 각각의 method이름은 Product개체에 대한 Name과 Price를 변경하는 test가 수행됨을 나타냅니다.
Method에 적용된 [Fact] attribute는 해당 Method자체가 test Method임을 나타내며 각 Method안에서 단위 test는 arange, act, assert라는 pattern을 따르고 있습니다. 이때 Arrange는 test를 위한 조건설정 부분이며 Act는 Test를 수행하고, Assert는 test의 결과를 검증하는 부분이 됩니다.
Arrange와 Act는 C# code로서 처리되지만 마지막 Assert영역은 xUnit.net에 의해 처리됩니다. xUnit는 Assert class의 method를 통해 test의 결과가 예상한 값과 일치하는지를 확인합니다.
Fact attribute와 Assert class는 Xunit의 namespace에 정의되어 있습니다. 때문에 모든 test class에서 using문을 사용해야 합니다.
Assert class의 method는 여러가지가 있는데 모두 static이며 예상값과 실제값 사이에 여러 가지 방식의 비교를 수행하는 데 사용됩니다. 아래 표는 일반적으로 사용되는 Assert의 method를 나타내고 있습니다.
Method (e : 예상값, r : 결과값) | 동작 |
Equal(e, r) | 예상값과 실제값이 일치하는지 여부를 비교합니다. 또한 다양한 유형과 Collection을 비교하기 위한 overload version의 method가 존재하며 개체의 비교를 위해 IEqualityComparer<T> interface를 구현하는 개체를 매개변수로 받는 method도 존재합니다. |
NotEqual(e, r) | 예상값과 실제값이 일치하지 않는지 여부를 비교합니다. |
True(r) | 결과가 true인지 여부를 확인합니다. |
False(r) | 결과가 false인지 여부를 확인합니다. |
IsType(e, r) | 결과가 특정한 type인지 여부를 확인합니다. |
IsNotType(e, r) | 결과가 특정한 type이 아닌지 여부를 확인합니다. |
IsNull(r) | 결과가 null인지 여부를 확인합니다. |
IsNotNull(r) | 결과가 null이 아닌지 여부를 확인합니다. |
InRange(r, 최저, 최고) | 결과가 최저와 최고사이에 속하는 값인지를 확인합니다. |
NotInRange(r, 최저, 최고) | 결과가 최저와 최고사이를 벗어나는지 여부를 확인합니다. |
Throws(예외, 표현식) | 지정한 표현식이 특정 예외 type을 throws하는지 확인합니다. |
각 Assert method는 다양한 유형의 비교를 수행할 수 있고 결과가 예상을 벗어나면 예외를 발생시키게 되며 이 예외를 통해 test가 통과되었는지를 알 수 있습니다. 위 예제에서는 Equal method를 통해 속성의 값이 정확히 변경되었는가를 확인하고 있습니다.
(1) Visual Studio에서 단위 test 실행하기
Visual Studio의 Test > Test Explorer를 실행하면 다음과 같은 화면을 볼 수 있습니다.
위 화면과 같이 Test Explorer에서는 사용가능한 단위 test를 찾고 이들을 실행할 수 있습니다.(만약 Test Explorer에서 작성한 단위 test가 나오지 않는다면 Solution을 build 해 보시기 바랍니다.)
화면상단의 제일 왼쪽에 있는 Run All Tests In View button을 눌러 단위 test를 실행합니다. 그러면 CanChangeProjectPrice는 test에 실패하게 되고 이에 따른 예외로 인해 다음과 같이 표시될 것입니다.
(2) Visual Studio Code에서 단위 test 실행하기
Visual Studio Code에서도 test를 감지하고 이를 실행할 수 있도록 지원하고 있습니다. Explorer에서 Mouse오른쪽 button을 눌러 Run Tests를 선택하면 단위 test를 실행하고 다음과 같은 결과를 표시합니다.
(3) Command line에서 단위 test 실행하기
Command line에서는 다음과 같은 명령을 통해 단위 test를 실행할 수 있습니다.
dotnet test |
그러면 단위 test를 확인하여 해당 test를 실행하고 그 결과를 다음과 같이 표시할 것입니다.
(4) 단위 test 통과하기
단위 test를 실행하면 위 예제와 같이 단위 test를 통과하지 못하는 경우도 있는데 그 결과를 보면 왜 통과하지 못했는지를 확인할 수 있습니다. 예제에서는 Price의 속성의 값(7000)과 변경되었을때 그 결과를 60000으로 예상한 것과 일치하지 않기 때문에 오류가 발생한 것입니다.
Test에 실패했다면 Test가 실제 수행되는 부분이 아닌 해당 test의 정확성을 먼저 확인해 보는 것이 좋습니다.
따라서 CanChnageProjectPrice test를 아래와 같이 변경합니다.
[Fact]
public void CanChangeProjectPrice()
{
//Arrange
Product p = new Product { Name = "SSD", Price = 55000 };
//Act
p.Price = 70000;
//Assert
Assert.Equal(70000, p.Price);
}
다시 단위 test를 실행한다면 이제 모든 test가 정확히 통과되었음을 확인할 수 있습니다. 특히 Visual Studio라면 Run Failed Tests를 사용해 통과하지 못한 단위 test만 진행할 수 있습니다.
(5) 단위 test를 위한 Component 격리하기
Product와 같이 Model Class를 사용한 단위 test만들기는 비교적 수월한 편입니다. Product가 다소 simple하며 독립적이므로 Product개체에 대한 작업을 수행할 때 Product class에서 제공되는 기능을 test 하고 있음을 확신할 수 있습니다.
그런데 .NET Core application에서 이러한 상황은 다른 Component에서 이들에 대한 의존성이 존재할 수 있고 그렇게 되면 더 복잡해질 수 있습니다. 다음 예제의 test는 Controller상에서 작동하도록 할 것이며 Controller와 View사이에 전달되는 Product 개체의 Sequence를 확인할 것입니다.
사용자 정의 class를 통해 instance화된 개체를 비교할 때는 IEqualityComparer<T> interface를 구현하는 인수를 받는 Assert.Equal method를 사용해 비교해야 합니다. 이를 위해 Comparer.cs file을 단위 test project에 추가하고 이를 통해 helper class를 정의합니다.
namespace UnitTest.Tests;
public class Comparer
{
public static Comparer<U?> Get<U>(Func<U?, U?, bool> func)
{
return new Comparer<U?>(func);
}
}
public class Comparer<T> : Comparer, IEqualityComparer<T>
{
private Func<T?, T?, bool> comparisonFunction;
public Comparer(Func<T?, T?, bool> func)
{
comparisonFunction = func;
}
public bool Equals(T? x, T? y)
{
return comparisonFunction(x, y);
}
public int GetHashCode(T obj)
{
return obj?.GetHashCode() ?? 0;
}
}
예제 Class는 비교하기 위한 각 type의 새로운 Class를 정의하기 보다는 lambda표현식을 사용해 IEqualityComparer<T>개체를 생성할 수 있도록 합니다. 반드시 이러한 방법을 사용해야 하는 것은 아니지만 단위 test Class를 간소화할 수 있고 유지관리를 더 쉽게 할 수 있도록 합니다.
이 상태에서 Application의 Component사이에 의존성 문제를 확인하기 위해 HomeControllerTests.cs file을 test project에 추가한 다음 이를 사용해 단위 test를 정의합니다.
using Microsoft.AspNetCore.Mvc;
using UnitTest.Controllers;
using UnitTest.Models;
namespace UnitTest.Tests;
public class HomeControllerTests
{
[Fact]
public void IndexActionModelIsComplete()
{
// Arrange
var controller = new HomeController();
Product[] products = new Product[] {
new Product { Name = "CPU", Price = 1000 },
new Product { Name = "Memory", Price = 5000 }
};
// Act
var model = (controller.Index() as ViewResult)?.ViewData.Model as IEnumerable<Product>;
// Assert
Assert.Equal(products, model, Comparer.Get<Product>((p1, p2) => p1?.Name == p2?.Name && p1?.Price == p2?.Price));
}
}
단위 test에서는 Product 개체에 대한 배열을 생성하고 그것을 Index action method에서 View mode로 제공하는 것에 해당하는지를 확인하고 있습니다. (예제에서 Act 부분은 추후에 자세히 설명할 것입니다. 지금은 code를 만들어 두기만 합니다.)
이것만으로 test를 실행할 수 있지만 test하는 Product data는 Product class로부터 직접 생성된 것으로 test결과 자체가 그리 유용해 보이지는 않습니다. 이 상태로라면 2개 이상의 Product 개체가 존재하거나 속성의 값이 다른 경우와 같은 상황에서 Controller가 정확히 동작하는 것인지에 관한 test를 만들 수 없습니다. 지금은 HomeController와 Product class의 결합된 동작과 이를 밀접하게 연결된 특정 개체를 통해서만 test 하고 있습니다.
단위 test는 method나 class와 같이 Application의 개별적인 부분만을 대상으로 할때 효과적입니다. 따라서 Application의 다른 부분에서 Home controller를 격리함으로써 test의 범위를 제한하고 repository에 의해 발생되는 모든 영향을 제외시킬 수 있습니다.
● Component 격리
Component는 interface를 사용해 격리합니다. Repository를 Controller와 분리하기 위해 IDataSource.cs file을 Model folder에 아래와 같이 추가합니다.
using UnitTest.Models;
namespace UnitTest;
public interface IDataSource
{
IEnumerable<Product> Products { get; }
}
그리고 Product class에서 기존의 정적 method를 제거하고 IDataSource interface를 구현하는 새로운 class를 생성합니다.
namespace UnitTest.Models;
public class Product
{
public string Name { get; set; } = string.Empty;
public int Price { get; set; } = 0;
// public static Product[] GetProducts()
// {
// Product CPU = new Product { Name = "CPU i7", Price = 250000 };
// Product Memory = new Product { Name = "Memory DDR5", Price = 120000 };
// return new Product[] { CPU, Memory };
// }
}
public class ProductDataSource : IDataSource
{
public IEnumerable<Product> Products =>
new Product[] {
new Product { Name = "CPU", Price = 1000 },
new Product { Name = "Memory", Price = 5000 }
};
}
그 다음 Data source로 ProductDataSource를 사용하도록 Controller를 아래와 같이 변경합니다.
이러한 방법 외에도 ASP.NET Core는 다양한 방법으로 의존성 주입에 관한 문제를 해결할 수 있도록 지원합니다.
public class HomeController: Controller
{
public IDataSource Ids = new ProductDataSource();
public ViewResult Index()
{
return View(Ids.Products);
}
}
이러한 방법을 통해 Controller에서는 test동안에 사용하는 data source를 변경할 수 있습니다. 따라서 Controller 단위 test를 아래와 같이 변경하여 지정한 Repository를 사용하도록 합니다.
public class HomeControllerTests
{
class TmpDataSource : IDataSource
{
public TmpDataSource(Product[] p) => Products = p;
public IEnumerable<Product> Products { get; set; }
}
[Fact]
public void IndexActionModelIsComplete()
{
// Arrange
Product[] tmpData = new Product[] {
new Product { Name = "CPU", Price = 1000 },
new Product { Name = "Memory", Price = 2000 },
new Product { Name = "Mainboard", Price = 3000 }
};
IDataSource data = new TmpDataSource(tmpData);
var controller = new HomeController();
controller.Ids = data;
// Act
var model = (controller.Index() as ViewResult)?.ViewData.Model as IEnumerable<Product>;
// Assert
Assert.Equal(data.Products, model, Comparer.Get<Product>((p1, p2) => p1?.Name == p2?.Name && p1?.Price == p2?.Price));
}
}
예제에서는 IDataSource interface를 임시로 구현하여 Controller에서 필요한 모든 test data를 사용할 수 있도록 하였습니다.
test 주도 개발
위 예제는 가장 일반적으로 사용되는 단위 test style을 따르고 있으며 여기에서 application 기능을 작성하고 제대로 작동하도록 test 하였습니다. 즉, Application 기능을 먼저 만들고 그 뒤에 해당 기능을 test 하는 순서입니다.
또 다른 test방식으로는 TDD(Test-Driven Development)가 있습니다. TDD도 다양한 방식이 존재하지만 핵심적인 개념은 application의 기능을 구현하기 전 test를 먼저 작성한다는 것입니다. Test를 먼저 작성하는 건 목적하는 바를 좀 더 정확하게 구현할 수 있도록 한다는 이점이 있습니다. TDD는 상세한 구현에 들어가기보다 해당 구현에 대한 성공 혹은 실패에 대한 척도를 미리 고려하는데 주안점을 두는 것입니다.
Test를 먼저 작성하므로 초기에 test는 모두 실패하게 되지만 Application에 code가 추가될 수록 test는 점진적으로 개선되어 가며 해당 구현이 완료되면 비로소 test가 올바르게 통과될 것입니다. TDD는 엄격한 규칙을 필요로 하지만 더욱 포괄적인 test설정을 만들며 더 강력하고 신뢰성 있는 code를 만들 수 있도록 합니다.
(6) Mockup package 사용하기
IDataSource interface를 임시적으로 구현해 놓는 것도 좋은 방법이지만 어디까지나 단순한 Class의 경우일때 얘기입니다. 따라서 이러한 대부분의 경우 우리는 mocking package를 사용해 임시적인 개체를 생성할 수 있습니다. 현재 많은 mocking package가 존재하지만 예제에서는 moq를 사용할 것입니다.
아래 명령을 통해 Moq를 Test project에 추가합니다.
dotnet add SimpleApp.Tests package Moq |
(7) Mock 개체 생성
Moq package를 추가하고 나면 따로 test class를 정의하지 않아도 임시적인 IDataSource개체를 생성할 수 있습니다.
using Microsoft.AspNetCore.Mvc;
using Moq;
using UnitTest.Controllers;
using UnitTest.Models;
namespace UnitTest.Tests;
public class HomeControllerTests
{
// class TmpDataSource : IDataSource
// {
// public TmpDataSource(Product[] p) => Products = p;
// public IEnumerable<Product> Products { get; set; }
// }
[Fact]
public void IndexActionModelIsComplete()
{
// Arrange
Product[] tmpData = new Product[] {
new Product { Name = "CPU", Price = 1000 },
new Product { Name = "Memory", Price = 2000 },
new Product { Name = "Mainboard", Price = 3000 }
};
var moq = new Mock<IDataSource>();
moq.SetupGet(m => m.Products).Returns(tmpData);
var controller = new HomeController();
controller.Ids = moq.Object;
// Act
var model = (controller.Index() as ViewResult)?.ViewData.Model as IEnumerable<Product>;
// Assert
Assert.Equal(tmpData, model, Comparer.Get<Product>((p1, p2) => p1?.Name == p2?.Name && p1?.Price == p2?.Price));
moq.VerifyGet(m => m.Products, Times.Once);
}
}
예제에서는 구현이 필요한 interface를 지정해 Mock 개체의 instance를 생성하고 있습니다. 이렇게 생성된 개체는 임시적인 개체로서 Products property에 대한 구현을 생성하기 위해 SetupGet method를 사용하고 있습니다. SetupGet method는 property에 대해 getter를 구현하며 method의 인수로 lambda 표현식을 사용하여 구현될 속성을 지정합니다. 예제에서 사용한 속성은 Products입니다. Returns method는 SetupGet method의 결과에 대한 method로 property값을 읽고 나면 반환될 결과를 지정합니다.
Mock class는 Object property정의하며 정의된 동작으로 지정한 interface를 구현하는 개체를 반환합니다. 예제에서는 Object속성을 사용하여 HomeController에 정의된 dataSource(Ids) field를 설정하고 있습니다.
마지막으로 VerifyGet method는 test가 완료된 후 mock object개체의 상태를 확인하는 것으로 매개변수로 지정된 Times.Once로 Products method가 한번만 읽혀야 함을 나타내고 있습니다. 만약 해당 조건이 맞지 않는 상태라면 예외를 일으키게 되고 이는 곧 test의 실패로 연결됩니다.
위와 같이 Moq를 사용함으로 인해 이전에 구현했던 TmpDataSource class를 제거할 수 있게 되었고 좀 더 간결한 방법으로 필요한 개체를 생성할 수 있게 되었습니다. 전체적인 효과는 임시로 interface를 구현한 이전과 동일하지만 mocking은 훨씬 유연하고 간결한 이점을 제공합니다.
'.NET > ASP.NET' 카테고리의 다른 글
[ASP.NET Core] - 7. Shopping mall project 만들기 - 2 (2nd) (0) | 2024.04.15 |
---|---|
[ASP.NET Core] - 6. Shopping mall project 만들기 - 1 (2nd) (2) | 2024.04.05 |
[ASP.NET Core] - 4. 개발도구 사용하기 (2nd) (0) | 2024.03.22 |
[ASP.NET Core] - 3. ASP.NET Core Application 예제 (2nd) (0) | 2024.03.20 |
[ASP.NET Core] - 2. 시작하기 (2nd) (0) | 2024.03.15 |