이제 Cart까지 기능을 완성하였는데 지금부터는 외형적 기능이 아닌 내부적인 개선에 집중해 보고자 합니다.
1. Service를 통한 Cart model 개선
지난번에는 Cart를 위해 Cart Model을 정의하였으며 Session기능을 통해 어떻게 이 개체를 저장할 수 있는지를 살펴보았습니다. Cart는 예제 Project에서 무엇보다 중요한 기능이며 지속적인 관리는 Session data로서 저장하고 가져오는 동작을 수행하는 Cart Razor Page를 통해 이루어져야 할 것입니다.
예제에서의 접근방법에 대한 문제는 다른 Razor page나 Controller에서 이를 사용하고자 할 때 Cart개체를 저장하고 가져오는 Code가 중복될 수 있다는 것입니다. 이를 개선하기 위해 ASP.NET Core의 가장 핵심적인 기능한 service를 사용하 Cart개체의 관리를 단순화하여 개별 구성요소가 상세한 면을 직접적으로 처리할 필요가 없도록 할 것입니다.
Service는 interface에 의존하는 구성요소로 부터 interface가 어떻게 구현되는지에 대한 세부사항을 숨기기 위해 사용되기도 하지만 그 외 다른 많은 문제점들을 해결하기 위해서도 사용되며 Cart와 같은 class를 구체화하는 중에 application을 개선하기 위해서도 사용되곤 합니다.
(1) Storage 인식 Cart class 생성
Cart class를 사용하는 방법을 개선하기 위한 첫번째 절차는 session state를 사용해 자체적으로 어떻게 저장할지를 인식하는 하위 class를 만드는 것입니다. 이를 위해 아래와 같이 Cart class에 virtual keyword를 적용하여 member를 override 할 수 있도록 합니다.
public virtual void AddItem(Product product, int quantity)
{
CartItem? item = CartItems.Where(p => p.Product.ProductID == product.ProductID).FirstOrDefault();
if (item == null) {
CartItems.Add(new CartItem {
Product = product,
Quantity = quantity
});
}
else
item.Quantity += quantity;
}
public virtual void RemoveItem(Product product)
{
CartItems.RemoveAll(l => l.Product.ProductID == product.ProductID);
}
public decimal ComputeTotalValue()
{
return CartItems.Sum(e => e.Product.Price * e.Quantity);
}
public virtual void Clear() => CartItems.Clear();
다음으로 Models folder에 SessionCart.cs file을 아래와 같이 추가합니다.
using System.Text.Json.Serialization;
using CompuMallStore.Classes;
namespace CompuMallStore.Models;
public class SessionCart : Cart
{
[JsonIgnore]
public ISession? Session { get; set; }
public static Cart GetCart(IServiceProvider service)
{
ISession? session = service.GetRequiredService<IHttpContextAccessor>().HttpContext?.Session;
SessionCart cart = session?.GetJson<SessionCart>("Cart") ?? new SessionCart();
cart.Session = session;
return cart;
}
public override void AddItem(Product product, int quantity)
{
base.AddItem(product, quantity);
Session?.SetJson("Cart", this);
}
public override void RemoveItem(Product product)
{
base.RemoveItem(product);
Session?.SetJson("Cart", this);
}
public override void Clear()
{
base.Clear();
Session?.Remove("Cart");
}
}
SessionCart class는 Cart class의 하위 class이며 AddItem, RemoveItem 그리고 Clear method를 override하여 기반 구현을 호출한 뒤 ISession interface의 확장 method를 사용해 Session으로 Update 된 상태를 저장합니다. 해당 정적 GetCart method는 SessionCart 개체를 생성하기 위한 factory이며 ISession 개체를 제공함으로써 스스로를 저장할 수 있도록 합니다.
ISession 개체를 확보하는 것은 약간 복잡한데 HttpContext 개체로의 접근을 제공하는 IHttpContextAccessor service를 가져와 이를 통해 다시 ISession으로 접근해야 합니다. 이는 session이 일반적인 service로는 제공되지 않기 때문에 예제와 같이 간접적인 접근법을 사용해야 합니다.
(2) Service 등록
다음으로는 Cart class를 위한 service를 만드는 것입니다. 예제의 목표는 원활하게 저장되는 SessionCart개체로 Cart에 대한 요청을 만족시키는 것으로 Program.cs에서 아래와 같이 Service를 등록합니다.
builder.Services.AddSession();
builder.Services.AddScoped<Cart>(sp => SessionCart.GetCart(sp));
builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
var app = builder.Build();
AddScoped method에서는 Cart instance와 관련된 요청을 충족시키는데 사용될 동일한 개체를 지정합니다. 요청이 연결되는 방식은 설정이 가능하지만 기본적으로 Component가 동일한 HTTP요청을 처리하는데 필요한 모든 Cart는 동일한 개체를 수신하게 됩니다.
AddScoped method를 통해 type에 대한 연결을 제공하기 보다는 예제에서는 repository에 대해 Cart요청을 충족시키는데 호출될 lambda 표현식을 지정하였습니다. 표현식은 등록된 service의 collection을 수신하고 SessionCart class의 GetCart method로 해당 collection을 전달합니다. 결과적으로 Cart service에 대한 요청은 수정될 때 session data로서 직렬화될 SessionCart 개체를 생성함으로써 처리됩니다.
또한 예제에서는 항상 사용될 동일한 개체를 지정하는 AddSingleton method를 사용하여 service를 추가하였습니다. 해당 service는 ASP.NET Core가 IHttpContextAccessor interface의 구현이 필요할때 HttpContextAccessor class를 사용하도록 하는 것인데 SessionCart class의 현재 session에 대한 접근이 필요하기 때문에 추가된 것입니다.
(3) Cart Razor Page 간소화
Service를 사용하면 Cart개체가 사용되어야 하는 곳에서의 code를 간소화시킬 수 있습니다. 아래 예제는 Cart.cshtml.cs를 변경한 것으로 위에서 등록한 service를 사용해 code를 개선한 것입니다.
public class CartModel : PageModel
{
private IStoreRepository repository;
public CartModel(IStoreRepository repo, Cart cart)
{
repository = repo;
Cart = cart;
}
public Cart Cart { get; set; }
public string ReturnUrl { get; set; } = "/";
public void OnGet(string returnUrl)
{
ReturnUrl = returnUrl ?? "/";
//Cart = HttpContext.Session.GetJson<Cart>("cart") ?? new Cart();
}
public IActionResult OnPost(long productId, string returnUrl)
{
Product? product = repository.Products.FirstOrDefault(p => p.ProductID == productId);
if (product != null) {
Cart.AddItem(product, 1);
// Cart = HttpContext.Session.GetJson<Cart>("cart") ?? new Cart();
// Cart.AddItem(product, 1);
// HttpContext.Session.SetJson("cart", Cart);
}
return RedirectToPage(new { returnUrl = returnUrl });
}
}
예제의 page model class에서는 생성자 매개변수를 선언하여 개체생성에 Cart object가 필요함을 말해주고 있으며 이를 통해 handler method로 부터 session을 저장하고 삭제하는 문을 제거할 수 있게 되었습니다. 간소화된 page model class에서는 Cart 개체를 생성하고 지속화할 수 있는 부분에 대한 걱정 없이 본연의 역할에만 집중할 수 있게 되었습니다. 또한 service는 Application전역에서 사용할 수 있으므로 모든 구성요소에서 필요한 경우 사용자의 Cart data에 접근할 수 있습니다.
단위 TEST
상기 예제의 CartModel class 변경으로 인해 test project의 CartTest역시 test를 위한 변경이 필요합니다. 따라서 생성자 인수로서 Cart를 제공하고 context개체에서 접근하지는 않도록 해야 합니다.
// Action
CartModel cartModel = new CartModel(mockRepo.Object, testCart);
cartModel.OnGet("testUrl");
2. Cart 기능 완성하기
마지막으로 2가지 다른 기능을 부여함으로서 Cart 기능을 완성하고자 합니다. 첫 번째는 사용자가 Cart에서 item을 제거하고 두 번째는 Cart상태를 Page의 상위에 표시하는 것입니다.
(1) Cart에서 Item삭제하기
Cart에서 Item을 삭제하기 위해 Remove link을 HTTP 요청을 보낼 Cart Razor Page에 추가할 것입니다. 이를 위해 Cart.cshtml file을 아래와 같이 변경합니다.
...생략
<thead>
<tr>
<th>Quantity</th>
<th>Item</th>
<th>Price</th>
<th>Subtotal</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var line in Model.Cart?.CartItems ?? Enumerable.Empty<CartItem>()) {
<tr>
<td>@line.Quantity</td>
<td>@line.Product.Name</td>
<td>
@line.Product.Price.ToString("c")
</td>
<td>
@((line.Quantity * line.Product.Price).ToString("c"))
</td>
<td>
<button type="submit" asp-page-handler="Remove" asp-route-ProductID="@line.Product.ProductID" asp-route-returnUrl="@Model?.ReturnUrl">Remove</button>
</td>
</tr>
}
</tbody>
...생략
예제에서 추가한 Remove button는 page model class(Cart.cshtml.cs)에서 다음과 같은 처리 method를 필요로 하며 여기에서 요청을 수신하고 Cart를 수정할 것입니다.
public IActionResult OnPostRemove(long productId, string returnUrl)
{
Cart.RemoveItem(Cart.CartItems.First(p => p.Product.ProductID == productId).Product);
return RedirectToPage(new { returnUrl = returnUrl });
}
Remove button에서 정의된 HTML form에서는 처리 method를 asp-page-handler에서 지정하고 있으며 이때 이름에서 On접두사와 요청과 일치하는 접미사가 붙어 OnPost가 되며 그다음 지정한 값의 이름이 붙어 OnPostRemove가 됩니다. 해당 method는 값을 받아 Cart에서 해당 item을 찾고 삭제할 것입니다. Project를 실행하고 Add To Cart로 Cart에 item을 추가한 뒤 Remove로 삭제하여 Cart에서 Item이 정상적으로 제거되는지 확인합니다.
(2) cart 정보표시 widget 추가
Cart의 전체적인 기능은 이것으로 완성하였지만 사용자가 Cart를 다루는 데는 여전히 문제가 있습니다. 사용자는 현재 Cart에 어떤 Item이 존재하는지를 Cart page에만 확인할 수 있으며 이 page는 Cart에 item을 추가해야만 볼 수 있습니다.
이 문제점을 해결하기 위해 application전역에서 Cart의 현재 상태를 표시하는 작은 weget을 추가하고 이를 click하면 Cart page로 이동할 수 있도록 할 것입니다. 그리고 이 작업은 Razor layout에 view component로서 navigation widget을 추가한 것과 거의 같은 방법으로 구현합니다.
● View component와 View class만들기
CartViewComponent.cs이름의 file을 project의 Components folder에 아래와 같이 추가합니다.
using CompuMallStore.Models;
using Microsoft.AspNetCore.Mvc;
namespace CompuMallStore.Components;
public class CartViewComponent : ViewComponent
{
private Cart cart;
public CartViewComponent(Cart cartService)
{
cart = cartService;
}
public IViewComponentResult Invoke()
{
return View(cart);
}
}
예제의 Component는 생성자 매개변수를 통해 Cart개체를 받음으로써 Service의 이점을 잘 활용하고 있습니다. 해당 View component는 Cart개체를 View method로 전달하여 HTML조각을 생성하는 것으로 Layout에 포함시킬 것입니다. View component에 대한 View를 만들기 위해 Project에 Views > Shared > Components > Cart folder를 만들고 해당 folder에 Default.cshtml Razor view를 아래와 같이 추가합니다.
@model Cart
<div>
@if (Model.CartItems.Count() > 0) {
<small class="navbar-text">
<b>Your cart:</b>
@Model.CartItems.Sum(x => x.Quantity) item(s)
@Model.ComputeTotalValue().ToString("c")
</small>
}
<a asp-page="/Cart" asp-route-returnurl="@ViewContext.HttpContext.Request.PathAndQuery()">
Cart
</a>
</div>
Cart에 Item이 존재한다면 전체 item 수에 대한 간단한 정보와 최종 단가를 표시할 것입니다. View와 View component가 준비되었으므로 Layout을 변경하여 Cart요약정보가 Home controller에 의해 생성되는 응답에 포함될 수 있도록 합니다.
...생략
<body>
<div>
<vc:cart></vc:cart>
</div>
<div class="row">
<div class="column"><vc:navigation /></div>
<div class="column">@RenderBody()</div>
</div>
</body>
Application을 시작하면 단순히 Cart라고 표시된 것만 볼 수 있는데 이 상태에서 item을 cart에 추가하면 cart에 존재하는 item의 수와 단가를 표시할 것입니다.
3. 주문완료하기
예제 Project에서 마지막 남은 기능은 Cart에 담긴 item에 대한 주문을 완료하는 것으로, 이를 위해 data model을 확장하여 사용자로부터 주문상세를 받을 수 있는 기능을 추가할 것입니다.
(1) Model class 만들기
Project의 Models foler에 Order.cs이름의 file을 아래와 같이 추가합니다. 해당 class는 사용자의 주문내역 상세를 표현하는 데 사용될 것입니다.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace CompuMallStore.Models;
public class Order
{
[BindNever]
public int OrderID { get; set; }
[BindNever]
public ICollection<CartItem> Items { get; set; } = new List<CartItem>();
[Required(ErrorMessage = "Please enter a name")]
public string? Name { get; set; }
[Required(ErrorMessage = "Please enter a phone number")]
public string? Phone { get; set; }
[Required(ErrorMessage = "Please enter a address")]
public string? Address { get; set; }
}
예제에서는 System.ComponentModel.Data Annotations에 정의되어 있는 validation attribute를 사용하였습니다. 여기서 BindNever attribute는 사용자가 HTTP요청에서 이들 속성에 값을 부여하지 못하게 합니다. model binding system에 관해 추후 상세히 알아볼 때 알 수 있겠지만 이를 통해 ASP.NET Core가 HTTP요청에서 민감하거나 혹은 중요한 속성에 사용자가 전달한 임의의 값으로 할당하게 되는 상황을 방지할 수 있습니다.
(2) 주문완료 link 추가하기
이제 사용자로부터 주문완료에 필요한 정보를 입력받고 주문을 완료하는 것만 남았습니다. 이를 위해 Checkout button을 Cart view에 아래와 같이 추가합니다.
<div>
<a href="@Model?.ReturnUrl">Continue shopping</a>
</div>
<div>
<a asp-action="Checkout" asp-controller="Order">Checkout</a>
</div>
예제에서 추가한 link는 Order Controller의 Checkout method를 호출하는 것으로 주문을 완료한 뒤 완료 page를 표시할 것입니다.
(3) Controller와 View 만들기
우선 주문을 처리할 Controller를 정의하기 위해 Project의 Controllers folder에 OrderController.cs이름의 file을 아래와 같이 추가합니다.
using CompuMallStore.Models;
using Microsoft.AspNetCore.Mvc;
namespace CompuMallStore.Controllers;
public class OrderController : Controller
{
public ViewResult Checkout()
{
return View(new Order());
}
}
예제의 Checkout method는 기본 View를 반환하는데 이때 View model로서 Order개체를 전달합니다. 이제 View를 만들기 위해 Views > Order folder를 생성하고 Checkout.cshtml 이름의 View file을 아래와 같이 추가합니다.
@model Order
<h2>Check out</h2>
<form asp-action="Checkout" method="post">
<h3>Ship to</h3>
<div>
<label>Name:</label>
<input asp-for="Name" />
</div>
<h3>Phone</h3>
<div>
<label>Phone:</label>
<input asp-for="Phone" />
</div>
<h3>Address</h3>
<div>
<label>Address:</label>
<input asp-for="Address" />
</div>
<div>
<input type="submit" value="Complete Order" />
</div>
</form>
예제에서는 Model의 각 속성과 관련된 label과 tag helper를 사용한 input 요소를 추가하고 사용자로부터 배송정보에 대한 입력을 받도록 하고 있습니다. input요소에서 사용된 asp-for는 기본 tag helper에 의해 처리된 속성으로 지정한 model속성에 기반하여 값과 type, id, name 등을 생성하게 됩니다.
예제를 실행하고 장바구니에 item을 추가한 뒤 Checkout link를 click 하여 아래와 같은 화면이 표시되는지를 확인합니다. 혹은 /Order/Checkou URL을 호출하여 직접적으로 해당 page를 확인합니다.
(4) 주문처리구현
예제에서는 마지막 주문처리를 위해 database를 사용할 것입니다. 물론 신용 Card나 기타 다른 결제 system까지 도입하지는 않겠지만 간단하게나마 database의 연동절차를 진행해보고자 합니다.
● Database 확장
이미 예제에서는 Database에 대한 초기설정에 대한 처리가 마련되어 있으므로 Database에 새로운 model을 추가하는 것은 그리 어렵지 않습니다. 우선 아래와 같이 Models foler의 StoreDbContext.cs file에 정의되어 있는 database context class에 새로운 속성을 추가합니다.
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
위와 같은 변경사항은 Entity Framework Core가 Database에 Order개체를 저장할 수 있도록 database migration을 생성하기 위한 사전준비에 해당합니다. migration을 생성하기 위해 command prompt를 사용하여 Project folder에서 아래 명령을 실행합니다.
dotnet ef migrations add Orders |
만약 아래와 같은 오류가 발생하는 경우
아래 명령을 사용하여 migration을 삭제한 다음 application을 다시 build 하고 migration을 추가해 봅니다.
The name 'Orders' is used by an existing migration.
dotnet ef migrations remove
dotnet build
dotnet ef migrations add Orders
위 명령은 Entity Framework Core가 application data model의 새로운 snapshot을 확인하고 이전의 database version과 어떻게 다른지를 비교한 다음 Orders라는 새로운 migration을 생성하도록 합니다. 이 새로운 migration은 SeedData에서 Entity Framework Core에 의해 제공되는 Migrate method를 호출하기 때문에 Application이 시작할 때 자동적으로 적용될 것입니다.
Database 초기화
Model을 자주 변경하는 경우 Application의 migration과 database schema가 제대로 동기화되지 않을 수 있습니다. 이런 문제를 해결하기 가장 좋은 방법은 아래 명령을 통해 database를 삭제하고 새롭게 생성하는 것입니다. 물론 이 방법은 그동안의 모든 data를 삭제하는 결과를 가져오므로 개발과정에서만 실행해야 합니다.
위 명령을 통해 database가 삭제되고 나면 Project folder에서 아래 명령을 다시 실행하여 database를 재생성하고 migration을 적용하도록 합니다.
dotnet ef database drop --force --context StoreDbContext
migration은 application을 재시작하기만 하면 SeedData class에 의해 적용될 것입니다.
dotnet ef database update --context StoreDbContext
● order repository 만들기
예제에서는 Order개체의 접근을 위해 product repository에서 사용했던 것과 동일한 pattern을 따를 것입니다. Project의 Models folder에 IOrderRepository.cs이름의 file을 아래와 같이 축가 합니다.
namespace CompuMallStore.Models;
public interface IOrderRepository
{
IQueryable<Order> Orders { get; }
void SaveOrder(Order order);
}
위의 order repository interface를 구현하기 위해 Models foler에 OrderRepository.cs file을 아래와 같이 추가합니다.
using Microsoft.EntityFrameworkCore;
namespace CompuMallStore.Models;
public class OrderRepository : IOrderRepository
{
private StoreDbContext context;
public OrderRepository(StoreDbContext ctx)
{
context = ctx;
}
public IQueryable<Order> Orders
{
get {
return context.Orders.Include(o => o.Items).ThenInclude(o => o.Product);
}
}
public void SaveOrder(Order order)
{
context.AttachRange(order.Items.Select(o => o.Product));
if (order.OrderID == 0)
context.Orders.Add(order);
context.SaveChanges();
}
}
위 예제는 Entity Framework Core를 사용해 IOrderRepository interface를 구현하고 있으며 저장된 Order개체를 확인하고 특정 Order를 생성하거나 변경할 수 있습니다.
Order Repository
Entity Framework Core는 다수의 table이 걸쳐있는 경우 관련된 data를 가져오기 위한 별도의 방법이 필요합니다. 위 예제에서는 Include와 ThenInclude method를 사용해 database에서 Order개체를 가져올 때 Items속성과 관련된 collection을 해당 개체와 관련된 각각의 Product 개체와 함께 가져올 수 있도록 하고 있습니다. 이렇게 하면 별도로 질의를 수행하여 각각의 data를 조합할 필요 없이 필요로 하는 data를 가져올 수 있습니다.
예제에서는 또한 Order개체를 database에 저장할 때 별도의 처리를 수행하고 있습니다. Session store부터 사용자의 Cart가 역직렬화될 때 새로운 Cart개체가 생성되는데 이는 Entity Framework Core가 알 수 없고 그 상태로 모든 개체를 database에 저장하기를 시도할 수 있습니다. Order와 관련된 Product개체의 경우 이는 Entity Framework Core가 이미 저장된 개체를 다시 저장하게 됨을 의미하게 되고 따라서 곧 예외를 유발하게 됩니다. 이러한 문제를 피하기 위해서는 Entity Framework Core에게 이미 존재하는 개체가 수정되지 않은 한 다시 저장하지 말 것을 알려야 하며 예제에서는 이러한 동작을 위해 AttachRange method를 사용하고 있습니다. 따라서 Entity Framework Core는 역직렬화된 Order개체 관련 Product개체는 Database에 저장하는 시도는 하지 않을 것입니다.
Program.cs에서는 Service로서 order repository를 아래와 같이 등록합니다.
builder.Services.AddScoped<IStoreRepository, EFStoreRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
(5) Order Controller 개선하기
OrderController class에서는 생성자를 통해 주문을 처리할 service를 받을 수 있도록 하고 사용자가 Complete Order button을 click 할 때 HTTP form POST request요청을 처리할 Action method를 추가합니다.
using CompuMallStore.Models;
using Microsoft.AspNetCore.Mvc;
namespace CompuMallStore.Controllers;
public class OrderController : Controller
{
private IOrderRepository repository;
private Cart cart;
public OrderController(IOrderRepository repoService, Cart cartService)
{
repository = repoService;
cart = cartService;
}
public ViewResult Checkout()
{
return View(new Order());
}
[HttpPost]
public IActionResult Checkout(Order order)
{
if (cart.CartItems.Count() == 0) {
ModelState.AddModelError("", "Error, the cart is empty!");
}
if (ModelState.IsValid) {
order.Items = cart.CartItems.ToArray();
repository.SaveOrder(order);
cart.Clear();
return RedirectToPage("/Completed", new { orderId = order.OrderID });
} else {
return View();
}
}
}
새롭게 추가된 Checkout method는 HttpPost attribute가 적용되어 있어 POST요청을 처리하는 데 사용될 것이며 이 경우 사용자가 전송한 form data가 됩니다.
이전에는 ASP.NET Core model binding 기능을 사용해 요청으로부터 간단하게나마 data를 받아오는 방법을 사용했는데 예제에서도 마찬가지로 Order개체를 받을 수 있게 같은 기능을 사용하고 있습니다. 요청이 처리되면 model binding system은 Order class에 정의된 속성을 통해 값을 찾게 될 것입니다. 이는 가장 가능한 한 범위 안에서만 작동하게 되므로 요청에 해당 data item이 존재하지 않는 경우 속성값이 비어있는 Order개체가 수신될 수도 있습니다.
필요한 data가 존재한다는 것을 확신하기 위해서는 Order class에 validation attribute를 적용할 필요가 있습니다. ASP.NET Core는 Order class에 적용된 제약사항을 확인한 뒤 ModelState속성을 통해 그 결과에 대한 상세정보를 제공합니다. 이때 ModelState.IsValid 속성을 확인함으로써 어떠한 문제가 있었는지를 알 수 있습니다. 예제에서는 CartItem이 존재하지 않는 경우 ModelState.AddModelError속성을 사용해 error message를 등록하고 있습니다.
단위 TEST
OrderController class에 대한 단위 test를 수행하기 위해 POST 요청을 받는 Checkout method의 동작을 test 해 볼 것입니다. 비록 method자체는 간단한 편에 속하지만 안에서 사용된 model binding은 test를 필요로 하는 많은 많은 동작이 이면에서 수행되고 있음을 의미합니다.
주문처리는 Cart에 Item이 존재하고 고객이 주문에 필요한 추가적인 정보를 제공하는 경우에만 처리되어야 하고 그게 아니라면 고객은 관련 오류를 마주해야 합니다. method를 test 하기 위해 test project에서 OrderControllerTests.cs file을 아래와 같이 추가합니다.
using CompuMallStore.Controllers;
using CompuMallStore.Models;
using Microsoft.AspNetCore.Mvc;
using Moq;
namespace CompuMallStore.Tests;
public class OrderControllerTests
{
[Fact]
public void CheckEmptyCart()
{
//Arranges
Mock<IOrderRepository> mock = new Mock<IOrderRepository>();
Cart cart = new Cart();
Order order = new Order();
OrderController target = new OrderController(mock.Object, cart);
//Act
ViewResult? result = target.Checkout(order) as ViewResult;
//Assert
mock.Verify(o => o.SaveOrder(It.IsAny<Order>()), Times.Never);
Assert.True(string.IsNullOrEmpty(result?.ViewName));
Assert.False(result?.ViewData.ModelState.IsValid);
}
}
위 test는 비어있는 Cart로 주문완료까지 진행될 수 있는지에 대한 여부를 test 하는 것으로 mock IOrderRepository구현의 SaveOrder가 호출되지 않는지, method가 반환하는 view가 기본 view(고객이 입력한 data를 다시 표시하여 올바른 값으로 다시 입력할 수 있도록 하는)인지를 확인하고 View로 전달할 model state가 잘못되어 있음을 표시하고 있는지 확인하고 있습니다. Assert 부분에서만 여러 검증과정을 거치고 있는데 이는 동작이 정확하게 이루어지고 있음을 확인하기 위한 것입니다. 다음 test 역시 거의 같은 방법으로 동작하긴 하지만 error를 view model로 주입하여 model binder에 의해 문제점이 표시되는 걸 묘사하고 있습니다.
[Fact]
public void CheckInvalidOrder()
{
//Arrange
Mock<IOrderRepository> mock = new Mock<IOrderRepository>();
Cart cart = new Cart();
cart.AddItem(new Product(), 1);
OrderController target = new OrderController(mock.Object, cart);
target.ModelState.AddModelError("error", "error");
//Act
ViewResult? result = target.Checkout(new Order()) as ViewResult;
//Assert
mock.Verify(m => m.SaveOrder(It.IsAny<Order>()), Times.Never);
Assert.True(string.IsNullOrEmpty(result?.ViewName));
Assert.False(result?.ViewData.ModelState.IsValid);
}
Cart가 비었거나 유효하지 않은 세부정보로는 주문이 제대로 처리되지 않는다는 것을 확인하고 나면 반대로 이것이 적절한 상태일 때 주문이 처리되는지도 확인해 볼 필요가 있습니다.
[Fact]
public void CheckSubmitOrder()
{
//Arrange
Mock<IOrderRepository> mock = new Mock<IOrderRepository>();
Cart cart = new Cart();
cart.AddItem(new Product(), 1);
OrderController target = new OrderController(mock.Object, cart);
//Act
RedirectToPageResult? result = target.Checkout(new Order()) as RedirectToPageResult;
//Assert
mock.Verify(m => m.SaveOrder(It.IsAny<Order>()), Times.Once);
Assert.Equal("/Completed", result?.PageName);
}
Order class의 속성에 적용된 attribute를 통해 model binder가 자동으로 처리할 것이므로 잘못된 주문정보자체를 식별하는 test는 필요하지 않습니다.
(6) Validation error 표시
ASP.NET Core는 Order class에 적용된 validation attribute를 사용해 사용자의 data 유효성을 확인하는데 이것과 관련된 문제점을 표시하기 위한 작업은 간단하게 이루어질 수 있습니다. 이때는 사용자로부터 제공된 data의 validation 상태를 확인하고 발견된 각 문제점들에 대한 경고 message를 추가하는 내장 tag helper가 사용됩니다. 아래 예제는 이와 관련된 처리를 위해 Checkout.cshtml file에 tag helper로 처리될 HTML요소를 추가한 것입니다.
..생략
<h2>Check out</h2>
<div asp-validation-summary="All"></div>
<form asp-action="Checkout" method="post">
..생략
위와 같이 단 하나의 요소를 추가하는 것만으로 사용자에게 validation error를 표시할 수 있게 되었습니다. 예제를 실행하고 /order/checkout URL을 요청한 뒤 'Complete Order' button을 click 하여 아래와 같이 validation error가 표시되는지 확인합니다.
현재 예제는 사용자의 data가 server로 전송된 뒤에 유효성검증이 이루어집니다. 이를 server-side validation이라고 하며 ASP.NET Core를 통해 수행할 수 있는 유효성검증기능입니다. 그런데 이러한 방식의 문제점은 data가 server로 전송되고 그에 대한 결과 page가 생성될 때까지는 사용자에게 유효성 오류에 관한 내용을 전달할 수 없다는 것입니다. Server가 많은 요청을 받고 응답하는 과정에 있다면 최종결과가 표시되기까지 수초의 시간이 걸릴 수도 있습니다. 이러한 이유로 server-side validation은 일반적으로 client-side validation을 보완하는 용도로 사용되며 대게 JavaScript를 통해 form data가 server에 전송되기 전 유효성검증을 수행하게 됩니다. client-side validation에 관해서는 추후에 자세히 알아볼 것입니다.
(7) summary page 표시
주문을 완료하기 위해 주문정보와 함께 완료 message를 표시할 Razor Page가 필요하므로 Project의 Pages folder에 Completed .cshtml이름의 file을 아래와 같이 추가합니다.
@page
<h3>Thanks!</h3>
<span>Order number : @OrderId</span>
@functions
{
[BindProperty(SupportsGet = true)]
public string? OrderId { get; set; }
}
Razor Page는 자신만의 Model class를 가질 수 있지만 예제에서는 그렇게까지의 복잡한 code가 필요하지 않으므로 같은 file안에서 OrderId속성을 정의하고 BindProperty를 적용하였습니다. 이렇게 하면 해당 속성의 값을 Model binding system에 의해 확인할 수 있습니다.
이제 사용자는 Product를 선택하는 것부터 주문까지 모든 절차를 진행할 수 있습니다. 주문 과정에서 정확한 주문정보와 Cart에 Item을 제공한다면 ' Complete Order' button을 click 했을 때 아래와 같은 Page를 볼 수 있습니다.
Application이 Controller와 Razor Page사이를 이동방식에 주목하시기 바랍니다. ASP.NET Core가 제공하는 Application기능은 상호보완적이며 Application안에서 자유롭게 사용할 수 있습니다.
'.NET > ASP.NET' 카테고리의 다른 글
[ASP.NET Core] - 9. Shopping mall project 만들기 - 4 (2nd) (0) | 2024.04.22 |
---|---|
[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] - 5. 단위 Test (2nd) (0) | 2024.03.29 |
[ASP.NET Core] - 4. 개발도구 사용하기 (2nd) (0) | 2024.03.22 |