Repository and Unit of Work 디자인 패턴을 이용한 TDD(Test-driven Development)

다중 계층 아키텍처는 관심사 분리(SoC, Separation of Concerns) 원칙 구현의 하나로, 각 계층은 전체 프로세스 흐름 중 담당하는 작업에만 집중하여 프로그램 코드의 복잡도를 낮출 수 있습니다. 하지만 계층간 결합도가 높다면 여전히 낮은 테스트성(testability)으로 인해 단위 테스트와 테스트 주도 개발(TDD, Test-driven Development), 행위 주도 개발(BDD, Behavior-driven Development) 등의 방법을 적용하기 어렵습니다. 이 포스트의 주 목적은 비즈니스 논리에 대한 데이터베이스에 독립적인 단위 테스트를 작성하고 TDD를 적용하는 방법을 설명하는 것입니다.

테스트를 위한 데이터 접근 계층 추상화

데이터베이스에 종속된 코드를 테스트하기는 쉽지 않습니다. 운영환경이 아닌 테스트 데이터베이스를 사용하더라도 마찬가지입니다. 사람이 직접 테스트를 수행한다면 반복적인 테스트를 위해 매번 테스트 환경을 구성하는 절차를 밟아야하는데 이것은 매우 높은 비용이 요구되며 사람이 저지를 수 있는 실수의 가능성과 각 테스트가 서로에게 영향을 줄 수 있다는 문제를 가집니다. 테스트를 자동화하는 것도 만만치 않습니다. 각 테스트를 수행하기 전 데이터베이스를 시나리오에 맞게 변경해 줘야하는 데 간단한 문제가 아닙니다. 테스트 케이스가 많으면 많을 수록 테스트 작성도 어려워지고 테스트를 위한 자원소비도 엄청날 것입니다.

데이터베이스에 접근하는 얇은 계층을 추상화하는 것은 좋은 해결책입니다. 비즈니스 프로세스나 API 코드를 테스트하기 위해 각 테스트에 필요한 만큼의 적절한 데이터 저장소 환경을 제공하는 것이 가능해집니다. 다음과 같은 테스트를 위한 데이터 저장소를 고려할 수 있습니다.

  1. SQLite 등의 가벼운 데이터베이스 엔진
  2. 개체 컬렉션 기반의 메모리 데이터 저장소
  3. 모의 데이터 저장소

위의 방법들을 각 목적에 맞게 혼합하여 사용하는 것도 가능합니다. 중요한 것은 실제 데이터베이스에 독립적으로 코드가 단위 테스트될 수 있다는 점입니다.

예제 프로그램 소개

예제로 사용할 프로그램은 전자 상거래 서비스의 일부이며 관리자가 상품을 등록하고 일반 사용자가 상품을 열람하고 상품에 대한 댓글을 다는 API를 제공합니다. 초기 코드는 다중 계층으로 구성되어 있지 않습니다. 설명을 진행하며 단계적으로 구축해 나갈 것입니다.

도메인 모델

주요 모델은 상품과 댓글입니다.

Item

상품 엔터티를 나타내는 Item 클래스는 이름과 설명, 가격 속성을 가지며 복수개의 댓글 엔터티와 연관됩니다.

public class Item
{
    public long Id { get; set; }
    [Required]
    public string Name { get; set; }
    public string Description { get; set; }
    [Required]
    public decimal Price { get; set; }

    public ICollection<Comment> Comments { get; set; }
}

Comment

댓글 엔터티를 나타내는 Comment 클래스는 상품 식별자와 작성자 식별자, 댓글 내용, 작성일자 등의 속성을 가집니다. 작성자 식별자와 네비게이션 속성은 외부에 노출되지 않도록합니다. 그리고 외부에 노출되지만 엔터티와 매핑되지 않는 작성자 이름 속성이 포함되어 있습니다.

예제에서는 JSON 형식에 대해서만 외부에 노출되지 않는 속성을 처리하지만 실제 서비스에서는 지원하는 모든 형식에 대해 처리해주거나 노출을 위한 추가적인 모델을 작성해야 합니다.

public class Comment
{
    public long Id { get; set; }
    public long ItemId { get; set; }
    [Required, JsonIgnore]
    public string AuthorId { get; set; }
    [Required]
    public string Content { get; set; }
    public DateTime CreatedAt { get; set; }

    [JsonIgnore]
    public Item Item { get; set; }
    [JsonIgnore]
    public ApplicationUser Author { get; set; }
    [NotMapped]
    public string AuthorName { get; set; }
}

CommentBindingModel

댓글 입력을 위한 모델입니다. 댓글 내용을 포함합니다.

public class CommentBindingModel
{
    [Required]
    public string Content { get; set; }
}

ApplicationDbContext

본 프로젝트는 Entity Framework Code First를 사용해 데이터베이스를 관리합니다. 프로젝트 템플릿이 생성한 DbContextItemComment에 대한 DbSet<>을 추가합니다.

public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    public ApplicationDbContext()
        : base("DefaultConnection", throwIfV1Schema: false)
    {
#if DEBUG
        this.Database.Log += m => System.Diagnostics.Debug.WriteLine(m);
#endif
    }

    public static ApplicationDbContext Create()
    {
        return new ApplicationDbContext();
    }

    public DbSet<Item> Items { get; set; }
    public DbSet<Comment> Comments { get; set; }
}

API 컨트롤러

상품을 관리하고 댓글을 추가하는 API를 제공합니다. 컨트롤러 전체 코드를 둘러본 뒤에 각 동작 별로 설명하겠습니다.

using System;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;
using Commerce.Models;
using Microsoft.AspNet.Identity;

namespace Commerce.Controllers
{
    public class ItemsController : ApiController
    {
        private ApplicationDbContext db = new ApplicationDbContext();

        // GET: api/Items
        public IQueryable<Item> GetItems()
        {
            return db.Items;
        }

        // GET: api/Items/5
        [ResponseType(typeof(Item))]
        public async Task<IHttpActionResult> GetItem(long id)
        {
            Item item = await db.Items.FindAsync(id);
            if (item == null)
            {
                return NotFound();
            }
            var query = from c in db.Comments
                        where c.ItemId == item.Id
                        orderby c.CreatedAt
                        select new { Comment = c, AuthorName = c.Author.UserName };
            await query.ForEachAsync(e => e.Comment.AuthorName = e.AuthorName);

            return Ok(item);
        }

        // PUT: api/Items/5
        [Authorize(Roles = "Administrator")]
        [ResponseType(typeof(void))]
        public async Task<IHttpActionResult> PutItem(long id, Item item)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            if (id != item.Id)
            {
                return BadRequest();
            }

            db.Entry(item).State = EntityState.Modified;

            try
            {
                await db.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!ItemExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return StatusCode(HttpStatusCode.NoContent);
        }

        // POST: api/Items
        [Authorize(Roles = "Administrator")]
        [ResponseType(typeof(Item))]
        public async Task<IHttpActionResult> PostItem(Item item)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            db.Items.Add(item);
            await db.SaveChangesAsync();

            return CreatedAtRoute("DefaultApi", new { id = item.Id }, item);
        }

        // DELETE: api/Items/5
        [Authorize(Roles = "Administrator")]
        [ResponseType(typeof(Item))]
        public async Task<IHttpActionResult> DeleteItem(long id)
        {
            Item item = await db.Items.FindAsync(id);
            if (item == null)
            {
                return NotFound();
            }

            db.Items.Remove(item);
            await db.SaveChangesAsync();

            return Ok(item);
        }

        [Authorize]
        [Route("api/Items/{id}/Comments")]
        [ResponseType(typeof(Comment))]
        public async Task<IHttpActionResult> PostComment(long id, CommentBindingModel comment)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            Item item = await db.Items.FindAsync(id);
            if (item == null)
            {
                return NotFound();
            }

            Comment entity = new Comment
            {
                ItemId = item.Id,
                AuthorId = User.Identity.GetUserId(),
                Content = comment.Content,
                CreatedAt = DateTime.Now
            };
            db.Comments.Add(entity);
            await db.SaveChangesAsync();

            return Ok(entity);
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }

        private bool ItemExists(long id)
        {
            return db.Items.Count(e => e.Id == id) > 0;
        }
    }
}

GetItem

상품 식별자를 사용해 단일 상품을 조회합니다. 조회된 상품이 없으면 404 상태를 반환합니다. 조회된 상품이 있으면 상품과 연관된 댓글 목록을 조회합니다. 이때 댓글들은 작성일자를 기준으로 정렬되고 작성자 이름이 함께 조회됩니다.

[ResponseType(typeof(Item))]
public async Task<IHttpActionResult> GetItem(long id)
{
    Item item = await db.Items.FindAsync(id);
    if (item == null)
    {
        return NotFound();
    }
    var query = from c in db.Comments
                where c.ItemId == item.Id
                orderby c.CreatedAt
                select new { Comment = c, AuthorName = c.Author.UserName };
    await query.ForEachAsync(e => e.Comment.AuthorName = e.AuthorName);

    return Ok(item);
}

LINQ나 IQueryable<>에 익숙하지 않은 분들을 위해 부연 설명을 하면 query를 구성하는 코드는 CLR에서 실행되는 것이아니라 ForEachAsync 메서드가 호출되면 SQL문으로 번역되어 데이터베이스에 전달됩니다. 반면 ForEachAsync 메서드에 전달된 람다식은 식트리(expression tree)가 아닌 대리자(delegate)로 빌드되며 CLR에서 실행됩니다.

PostItem, PutItem, DeleteItem

상품을 등록하고 수정 및 삭제하는 동작은 관리자 역할을 가진 계정으로 제한합니다.

[Authorize(Roles = "Administrator")]

PostComment

지정한 상품에 댓글을 추가하는 기능을 정의합니다. CommentBindingModel 클래스의 Content 속성에 댓글 내용이 담겨 전달됩니다. 인증된 사용자로 접근이 제한되며 Comment 엔터티가 추가될 때 현재 인증된 사용자 정보를 바탕으로 작성자 식별자가 설정됩니다.

[Authorize]
[Route("api/Items/{id}/Comments")]
[ResponseType(typeof(Comment))]
public async Task<IHttpActionResult> PostComment(long id, CommentBindingModel comment)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    Item item = await db.Items.FindAsync(id);
    if (item == null)
    {
        return NotFound();
    }

    Comment entity = new Comment
    {
        ItemId = item.Id,
        AuthorId = User.Identity.GetUserId(),
        Content = comment.Content,
        CreatedAt = DateTime.Now
    };
    db.Comments.Add(entity);
    await db.SaveChangesAsync();

    return Ok(entity);
}

GetUserId() 메서드는 확장 메서드입니다. Microsoft.AspNet.Identity 네임스페이스가 포함되어야 합니다.

다중 계층 디자인

현재 모든 API 프로세스는 컨트롤러에 구현되어 있습니다. 앞으로 서비스가 확장되면 동일한 비즈니스 논리가 신규 API에 사용되거나 새로운 프로세스가 동일한 데이터에 접근할 수 있습니다. 이때 소스 코드의 복사가 이루어지면 관리가 어려워집니다. 프로그램을 역할을 기준으로 분리하면 코드가 단순해질 뿐 아니라 필요할 때 모듈을 재사용할 수 있습니다. API 코드에서 데이터 접근 코드와 비즈니스 논리를 분리하도록 하겠습니다.

데이터 접근

데이터베이스에서 상품 엔터티와 댓글 엔터티를 읽고 쓰는 기본적인 작업을 담당하는 계층을 정의합니다.

ItemDataAccess

using System;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using Commerce.Models;

namespace Commerce.DataAccess
{
    public class ItemDataAccess : IDisposable
    {
        private ApplicationDbContext _dbContext = new ApplicationDbContext();

        public IQueryable<Item> Query
        {
            get
            {
                return this._dbContext.Items;
            }
        }

        public Task<Item> FindAsync(long id)
        {
            return this._dbContext.Items.FindAsync(id);
        }

        public void Create(Item entity)
        {
            this._dbContext.Items.Add(entity);
        }

        public void Update(Item entity)
        {
            this._dbContext.Entry(entity).State = EntityState.Modified;
        }

        public void Delete(Item entity)
        {
            this._dbContext.Items.Remove(entity);
        }

        public Task SaveChangesAsync()
        {
            return this._dbContext.SaveChangesAsync();
        }

        public void Dispose()
        {
            this._dbContext.Dispose();
        }
    }
}

CommentDataAccess

using System;
using System.Linq;
using System.Threading.Tasks;
using Commerce.Models;

namespace Commerce.DataAccess
{
    public class CommentDataAccess : IDisposable
    {
        private ApplicationDbContext _dbContext = new ApplicationDbContext();

        public IQueryable<Comment> Query
        {
            get
            {
                return this._dbContext.Comments;
            }
        }

        public void Create(Comment entity)
        {
            this._dbContext.Comments.Add(entity);
        }

        public Task SaveChagesAsync()
        {
            return this._dbContext.SaveChangesAsync();
        }

        public void Dispose()
        {
            this._dbContext.Dispose();
        }
    }
}

비즈니스 프로세스

상품을 등록 및 관리하고 상품에 댓글을 추가하는 비즈니스 프로세스를 정의합니다. 데이터베이스 접근은 전적으로 데이터 접근 계층에 위임합니다.

ItemService

using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using Commerce.DataAccess;
using Commerce.Models;

namespace Commerce.Services
{
    public class ItemService : IDisposable
    {
        private ItemDataAccess _items = new ItemDataAccess();
        private CommentDataAccess _comments = new CommentDataAccess();

        public async Task<IEnumerable<Item>> GetItemsAsync()
        {
            return await this._items.Query.ToListAsync();
        }

        public async Task<Item> GetItemAsync(long id)
        {
            Item item = await this._items.FindAsync(id);
            if (item == null)
            {
                return null;
            }
            var query = from c in this._comments.Query
                        where c.ItemId == id
                        orderby c.CreatedAt
                        select new
                        {
                            Comment = c,
                            AuthorName = c.Author.UserName
                        };
            List<Comment> comments = new List<Comment>();
            await query.ForEachAsync(e =>
            {
                Comment comment = e.Comment;
                comment.AuthorName = e.AuthorName;
                comments.Add(comment);
            });
            item.Comments = comments;
            return item;
        }

        public async Task<Item> CreateItemAsync(Item item)
        {
            this._items.Create(item);
            await this._items.SaveChangesAsync();
            return item;
        }

        public async Task<Item> UpdateItemAsync(Item item)
        {
            if (false == await this.ItemExistsAsync(item.Id))
            {
                return null;
            }
            this._items.Update(item);
            await this._items.SaveChangesAsync();
            return item;
        }

        public async Task<Item> DeleteItemAsync(long id)
        {
            Item item = await this._items.FindAsync(id);
            if (item == null)
            {
                return null;
            }
            this._items.Delete(item);
            await this._items.SaveChangesAsync();
            return item;
        }

        public async Task<Comment> CreateCommentAsync(long itemId, string authorId, string content)
        {
            if (false == await this.ItemExistsAsync(itemId))
            {
                return null;
            }
            Comment comment = new Comment
            {
                ItemId = itemId,
                AuthorId = authorId,
                Content = content,
                CreatedAt = DateTime.Now
            };
            this._comments.Create(comment);
            await this._comments.SaveChagesAsync();
            return comment;
        }

        private Task<bool> ItemExistsAsync(long id)
        {
            return this._items.Query.AnyAsync(e => e.Id == id);
        }

        public void Dispose()
        {
            this._items.Dispose();
            this._comments.Dispose();
        }
    }
}

API 계층

이제 데이터베이스에 접근하고 비즈니스 논리를 처리하는 코드는 API 컨트롤러에 남아있지 않습니다. 더이상 ApplicationDbContext 인스턴스를 가지지 않으며 대신 ItemService 개체를 관리하며 비즈니스 서비스를 제공받습니다. 컨트롤러는 요청에 대한 유효성을 검사하고 응답 메시지를 작성하는 작업에 집중합니다.

ItemController

using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;
using Commerce.Models;
using Commerce.Services;
using Microsoft.AspNet.Identity;

namespace Commerce.Controllers
{
    public class ItemsController : ApiController
    {
        private ItemService _service = new ItemService();

        // GET: api/Items
        public Task<IEnumerable<Item>> GetItems()
        {
            return this._service.GetItemsAsync();
        }

        // GET: api/Items/5
        [ResponseType(typeof(Item))]
        public async Task<IHttpActionResult> GetItem(long id)
        {
            Item entity = await this._service.GetItemAsync(id);

            return Ok(entity);
        }

        // PUT: api/Items/5
        [Authorize(Roles = "Administrator")]
        [ResponseType(typeof(void))]
        public async Task<IHttpActionResult> PutItem(long id, Item item)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            if (id != item.Id)
            {
                return BadRequest();
            }

            if (null == await this._service.UpdateItemAsync(item))
            {
                return NotFound();
            }

            return StatusCode(HttpStatusCode.NoContent);
        }

        // POST: api/Items
        [Authorize(Roles = "Administrator")]
        [ResponseType(typeof(Item))]
        public async Task<IHttpActionResult> PostItem(Item item)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            Item entity = await this._service.CreateItemAsync(item);

            return CreatedAtRoute("DefaultApi", new { id = entity.Id }, entity);
        }

        // DELETE: api/Items/5
        [Authorize(Roles = "Administrator")]
        [ResponseType(typeof(Item))]
        public async Task<IHttpActionResult> DeleteItem(long id)
        {
            Item entity = await this._service.DeleteItemAsync(id);
            if (entity == null)
            {
                return NotFound();
            }

            return Ok(entity);
        }

        [Authorize]
        [Route("api/Items/{id}/Comments")]
        [ResponseType(typeof(Comment))]
        public async Task<IHttpActionResult> PostComment(long id, CommentBindingModel comment)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            Comment entity = await this._service.CreateCommentAsync(id, User.Identity.GetUserId(), comment.Content);
            if (entity == null)
            {
                return NotFound();
            }

            return Ok(entity);
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                this._service.Dispose();
            }
            base.Dispose(disposing);
        }
    }
}

문제점

역할에 따라 코드가 분리되었고 정상적으로 동작하지만 지금의 다중 계층 디자인은 몇 가지 문제점을 가지고 있습니다.

복수의 DbContext

ItemDataAccessCommentDataAccess는 각각 ApplicationDbContext 인스턴스를 관리합니다. 이것은 다음의 문제를 야기합니다.

  1. 불필요한 데이터베이스 연결 자원 소비가 발생합니다.
  2. 데이터 저장 작업이 분산됩니다.
  3. 네비게이션 속성을 이용해 연관된 데이터를 조회하면 엔터티 상태 관리가 이중화됩니다.

비즈니스 프로세스 단위 테스트의 어려움

다른 코드 역시 마찬가지지만 비즈니스 논리를 담당하는 코드에 있어서 단위 테스트는 매우 중요하며 자동화되어야 합니다. 하지만 현재 비즈니스 프로세스 계층은 데이터 접근 계층과 강하게 결합되어 있습니다. ItemService 개체가 ItemDataAccessCommentDataAccess 클래스를 직접 참조하기 때문입니다. 결국 이것은 포스트 초반에 언급했듯이 자동화된 단위 테스트 작성을 매우 어렵거나 불가능하게 만듭니다.

Repository and Unit of Work

우리가 가지고 있는 문제점들을 해결하기에 Repository and Unit of Work 디자인 패턴은 좋은 수단입니다. 비즈니스 프로세스 계층과 데이터 접근 계층 사이에 약한 결합을 제공하기 때문에 단위 테스드가 가능하고 TDD를 적용할 수도 있습니다.

전통적 방법을 따라 개발한다면 다음의 순서로 개발이 진행됩니다.

  1. IRepository<>, IUnitOfWork 인터페이스 설계
  2. IRepository<>, IUnitOfWork 인터페이스 구현체 작성
  3. ItemService 클래스 작성
  4. 단위 테스트
  5. 통합 테스트

조금의 차이는 있어도 큰 흐름은 이와 비슷할 것입니다. TDD를 따라 개발한다면 전체 프로세스는 큰 차이를 보입니다.

  1. 테스트 작성
  2. 테스트 실패
  3. 프로덕션 코드 작성
  4. 테스트 성공
  5. 리팩토링
  6. 반복
  7. 통합 테스트

예제를 통해 이 과정을 간단히 살펴보겠습니다.

테스트 준비

ItemService 클래스를 새로 만들 것입니다. 클래스 선언을 제외한 모든 코드를 삭제합니다. 그리고 테스트 작성에 도움을 주는 몇 가지 패키지를 설치하고 테스트 케이스들이 공유할 공통 코드를 작성합니다.

Moq

Moq은 훌륭한 테스트 더블 도구입니다. 저도 참 좋아하는데요, 제가 한 번 먹어…

PM> Install-Package -ProjectName:Commerce.Tests Moq

Fluent Assertions

Fluent Assertions은 BDD에 적합한 검증 코드를 작성하게 해주는 Fluent API를 제공합니다.

PM> Install-Package -ProjectName:Commerce.Tests FluentAssertions

EntityFramework.Testing.Moq

DbSet<>에 대한 테스트 더블을 만드는 것은 귀찮습니다. 특히 비동기 연산에 대해서 더욱 그렇습니다. IDbAsyncEnumerable<> 인터페이스를 처리해줘야 하기 때문입니다. 여기에 관련된 정보가 있습니다.

하지만 지난 주 EntityFramework.Testing.Moq 알파 버전이 배포되어 상황이 조금 달라졌습니다. Moq과 연동되는 DbSet<> 테스트 더블을 아주 손쉽게 만들어줍니다.

처음에는 이 패키지로 인해 Entity Framework을 사용할 때 더 이상 Repository and Unit of Work 계층이 필요 없어지는 것이 아닌가 생각이 들 정도였습니다. 조금 더 살펴보니 성급한 판단이었지만 그래도 훌륭합니다. 손발이 많이 편해지겠습니다.

DbEntityEntry의 테스트 환경 제어가 쉽지 않기 때문에 Entity Framework의 변화 없이 추상화된 데이터 접근 계층을 DbContext 자체로 감당하는 것은 불가능해 보입니다.

PM> Install-Package -ProjectName:Commerce.Tests -IncludePrerelease EntityFramework.Testing.Moq

테스트 공통 코드

ItemService 클래스에 대한 테스트 케이스에 공통적으로 사용될 코드입니다.

using System;
using System.Linq;
using System.Data.Entity;
using System.Threading.Tasks;
using Commerce.DataAccess;
using Commerce.Models;
using Commerce.Services;
using EntityFramework.Testing.Moq;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

namespace Commerce.Tests.Services
{
    [TestClass]
    public class ItemServiceTest
    {
        private Random _random;
        private long _itemId;
        private string _userId;
        private Mock<IRepository<Item>> _items;
        private Mock<IRepository<Comment>> _comments;
        private Mock<IUnitOfWork> _unitOfWork;
        private ItemService _service;

        [TestInitialize]
        public void Initialize()
        {
            this._random = new Random();
            this._itemId = this._random.Next();
            this._userId = Guid.NewGuid().ToString();
            this._items = new Mock<IRepository<Item>>();
            this._comments = new Mock<IRepository<Comment>>();
            this._unitOfWork = new Mock<IUnitOfWork>();
            this._unitOfWork.Setup(u => u.GetRepository<Item>()).Returns(this._items.Object);
            this._unitOfWork.Setup(u => u.GetRepository<Comment>()).Returns(this._comments.Object);
            this._service = new ItemService(this._unitOfWork.Object);
        }
    }
}

아직 존재하지 않는 IRepository<> 형식과 IUnitOfWork 형식이 등장합니다. Visual Studio는 TDD를 지원하는 기능을 가지고 있습니다. 오류가 발생한 코드의 컨텍스트 메뉴에서 ‘Generate’의 ‘New Type…’을 선택합니다.

generate-new-type

아래 화면에서 종류는 인터페이스로, 프로젝트와 파일 경로, 이름을 적절하게 설정하고 확인 버튼을 누릅니다.

generate-new-type-dialog

이런 방법으로 IRepository<>, IUnitOfWork 인터페이스를 생성합니다. 멤버 정의도 유사하게 만들 수 있습니다. 즉, TDD 프로세스에 맞게 테스트 코드를 먼저 작성한 후 프로덕션 코드(테스트 대상 형식과 멤버)의 골격을 쉽게 만들 수 있도록 도와줍니다.

generate-method-stub

실제 개발 과정에서는 아직 등장할 시점이 아니지만 설명을 돕기 위해 IRepository<>IUnitOfWork 인터페이스를 미리 둘러 보겠습니다.

IRepository<>

using System.Linq;
using System.Threading.Tasks;

namespace Commerce.DataAccess
{
    public interface IRepository<TEntity>
        where TEntity : class
    {
        Task<TEntity> FindAsync(params object[] keyValues);
        IQueryable<TEntity> Query { get; }
        void Create(TEntity entity);
        void Update(TEntity entity);
        void Remove(TEntity entity);
    }
}

조회를 위한 FindAsync() 메서드와 Query 속성을 정의하고 쓰기 접근을 위해서는 Create(), Update(), Remove() 메서드를 제공합니다. 쓰기 접근에 대해서 한 가지 중요한 사실이 있는데 IRepository<> 인터페이스에 의해 제공되는 쓰기 메서드는 즉시 데이터 저장소에 기록하지 않는다는 것입니다. 실제 기록 작업은 IUnitOfWork 인터페이스가 담당합니다.

다중 엔터티 조회를 제공하는 멤버를 정의하는 방법은 여러가지가 있습니다. 어떤 구현은 IQueryable<>을 숨기고 제한된 연산만을 공개합니다. 하지만 저는 C#의 편리한 쿼리식 문법과 Include() 메서드 등의 훌륭한 OR/M 도구를 비즈니스 논리 코드 작성에서 배제하고 싶지 않으며 EntityFramework.Testing.Moq의 지원까지 있으니 과감하게 IQueryable<>을 노출했습니다. 문제가 발생하면 그 때 가서 고민하는 걸로…

IUnitOfWork

using System;
using System.Threading.Tasks;

namespace Commerce.DataAccess
{
    public interface IUnitOfWork : IDisposable
    {
        IRepository<TEntity> GetRepository<TEntity>() where TEntity : class;
        Task SaveChangesAsync();
    }
}

IUnitOfWork 인터페이스는 두 개의 멤버를 정의합니다. GetRepository<>() 메서드는 제네릭 매개변수로 지정된 엔터티 형식에 대한 IRepository<> 구현 개체를 반환합니다. 그리고 IRepository<>를 통해 예약된 데이터 쓰기 접근에 대해 실제 기록 작업을 수행하는 SaveChangesAsync() 메서드가 있습니다. SaveChangesAsync() 메서드가 IRepository<>가 아닌 IUnitOfWork에 정의되어 있기 때문에 여러 형식의 엔터티에 대한 기록 작업을 한 번에 처리하는 것이 가능해집니다. 인터페이스 이름에 어울리는 역할이죠. 아니, 역할에 어울리는 이름이 맞겠네요. 🙂

트랜잭션에 대한 깊은 얘기는 건너뜁니다.

이제 다시 테스트 공통 코드로 돌아가서 Initialize() 메서드를 다시 한 번 살펴봅니다.

[TestInitialize]
public void Initialize()
{
    this._random = new Random();
    this._itemId = this._random.Next();
    this._userId = Guid.NewGuid().ToString();
    this._items = new Mock<IRepository<Item>>();
    this._comments = new Mock<IRepository<Comment>>();
    this._unitOfWork = new Mock<IUnitOfWork>();
    this._unitOfWork.Setup(u => u.GetRepository<Item>()).Returns(this._items.Object);
    this._unitOfWork.Setup(u => u.GetRepository<Comment>()).Returns(this._comments.Object);
    this._service = new ItemService(this._unitOfWork.Object);
}

Initialize() 메서드는 TestInitializeAttribute 특성에 의해 ItemServiceTest 클래스의 모든 테스트 케이스가 실행되기 전에 실행됩니다. ItemComment 엔터티에 대한 모의(mock) IRepository<>와 모의 IUnitOfWork 개체를 초기화하고 IUnitOfWorkGetRepository<>() 메서드가  ItemComment 엔터티에 대해 호출될 때 모의 개체가 반환되도록 설정합니다. 마지막으로 IUnitOfWork 모의 인스턴스를 사용해 ItemService 개체를 초기화합니다.

지금까지의 코드가 빌드되도록 ItemService 클래스의 생성자를 작성하는 것으로 테스트 준비를 완료합니다.

public class ItemService : IDisposable
{
    private IUnitOfWork _unitOfWork;

    public ItemService(IUnitOfWork unitOfWork)
    {
        this._unitOfWork = unitOfWork;
    }
}

GetItemsAsync

가장 단순한 GetItems 동작에 대한 비즈니스 논리 코드인 ItemService.GetItemsAsync() 메서드를 작성합니다. 여기서 중요한 것은 우리 관심사가 ItemService라는 것입니다. 데이터 접근 계층이 내부적으로 무슨 일을 하는지는 관심이 없으며 이것에 대한 고려가 테스트에 반영되어서도 안됩니다. GetItemsAsync() 메서드의 주된 임무는 IRepository<Item>Query 속성을 사용해 데이터 접근 계층이 제공하는 모든 Item 엔터티를 반환하는 것입니다. 당연히 테스트 코드도 이것만 검사하면 되고 또 이것만 검사해야 합니다.

조회 동작에 정렬, 필터링, 페이징 등의 기능이 추가되면 아마 가장 복잡한 동작이 되겠죠.

테스트 작성

[TestMethod]
public async Task GetItemsAsync_Should_ReturnAllItems()
{
    // Arrange
    var items = Enumerable.Range(0, 100).Select(_ => new Item()).ToList();
    var dbSet = new MockDbSet<Item>().SetupSeedData(items).SetupLinq();
    this._items.SetupGet(r => r.Query).Returns(dbSet.Object);

    // Act
    var result = (await this._service.GetItemsAsync()).ToList();

    // Assert
    result.Should().BeEquivalentTo(items);
}

EntityFramework.Testing.Moq 패키지가 제공하는 MockDbSet<> 클래스를 사용해 DbSet<>의 테스트 더블을 만들고 쉽게 설정할 수 있습니다. IRepository<>.Query 속성이 이 모의 DbSet<> 개체를 반환하도록 설정합니다. 테스트 수행은 GetItemsAsync() 메서드 호출 결과를 List<>로 캐싱하는 것이고 Fluent Assertions의 도움을 받아 엘레강스하게 검사 코드를 작성합니다.

테스트 실패

Visual Studio의 도움을 받아 GetItemsAsync() 멤버를 생성했다면 접근 즉시 NotImplementedException이 발생됩니다. 테스트를 실행하면 실패하겠죠. 예상된 당연하고 적절한 결과입니다. 오히려 혹시나 테스트가 성공하면 긴장하고 자신을 뒤돌아봐야 합니다.

프로덕션 코드 구현

public class ItemService : IDisposable
{
    private IUnitOfWork _unitOfWork;
    private IRepository<Item> _items;

    public ItemService(IUnitOfWork unitOfWork)
    {
        this._unitOfWork = unitOfWork;
        this._items = unitOfWork.GetRepository<Item>();
    }

    public async Task<IEnumerable<Item>> GetItemsAsync()
    {
        return await this._items.Query.ToListAsync();
    }
}

IUnitOfWork로부터 Item 엔터티에 대한 IRepository<>를 제공받습니다. GetItemsAsync() 메서드는 IRepository<>.Query 속성을 사용해 상품 목록을 조회해서 반환합니다.

GetItemsAsync() 메서드는 단 한 줄의 코드를 포함하는 데 이걸 테스트하는 코드는 여러 줄이네요. 네, 당황하지 마세요. 흔히 발견되는 광경입니다.

테스트 성공

실수가 없었다면 빌드와 테스트가 성공합니다. 기분이 아주 좋습니다.

이후의 TDD 절차에 대한 설명은 생략하겠습니다.

완성된 테스트와 프로덕션 코드

모든 비즈니스 서비스 연산에 대한 테스트와 프로덕션 코드 구현입니다.

ItemServiceTest

using System;
using System.Linq;
using System.Data.Entity;
using System.Threading.Tasks;
using Commerce.DataAccess;
using Commerce.Models;
using Commerce.Services;
using EntityFramework.Testing.Moq;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

namespace Commerce.Tests.Services
{
    [TestClass]
    public class ItemServiceTest
    {
        private Random _random;
        private long _itemId;
        private string _userId;
        private Mock<IRepository<Item>> _items;
        private Mock<IRepository<Comment>> _comments;
        private Mock<IUnitOfWork> _unitOfWork;
        private ItemService _service;

        [TestInitialize]
        public void Initialize()
        {
            this._random = new Random();
            this._itemId = this._random.Next();
            this._userId = Guid.NewGuid().ToString();
            this._items = new Mock<IRepository<Item>>();
            this._comments = new Mock<IRepository<Comment>>();
            this._unitOfWork = new Mock<IUnitOfWork>();
            this._unitOfWork.Setup(u => u.GetRepository<Item>()).Returns(this._items.Object);
            this._unitOfWork.Setup(u => u.GetRepository<Comment>()).Returns(this._comments.Object);
            this._service = new ItemService(this._unitOfWork.Object);
        }

        [TestMethod]
        public async Task GetItemsAsync_Should_ReturnAllItems()
        {
            // Arrange
            var items = Enumerable.Range(0, 100).Select(_ => new Item()).ToList();
            var dbSet = new MockDbSet<Item>().SetupSeedData(items).SetupLinq();
            this._items.SetupGet(r => r.Query).Returns(dbSet.Object);

            // Act
            var result = (await this._service.GetItemsAsync()).ToList();

            // Assert
            result.Should().BeEquivalentTo(items);
        }

        [TestMethod]
        public async Task GetItemAsync_Should_ReturnFindAsync()
        {
            // Arrange
            var item = new Item { Id = this._itemId };
            this._items.Setup(r => r.FindAsync(this._itemId)).ReturnsAsync(item);
            var dbSet = new MockDbSet<Comment>().SetupSeedData(Enumerable.Empty<Comment>()).SetupLinq();
            this._comments.SetupGet(r => r.Query).Returns(dbSet.Object);

            // Act
            Item result = await this._service.GetItemAsync(this._itemId);

            // Assert
            this._items.Verify(r => r.FindAsync(this._itemId), Times.Once());
            result.Should().Be(item);
        }

        [TestMethod]
        public async Task GetItemAsync_Should_ReturnItemWithSortedRelatedComments()
        {
            // Arrange
            var item = new Item { Id = this._itemId };
            this._items.Setup(r => r.FindAsync(this._itemId)).ReturnsAsync(item);
            var comments = Enumerable.Range(0, 100).Select(_ =>
                new Comment
                {
                    ItemId = this._random.Next() % 2 == 0 ? this._itemId : this._itemId + 1,
                    Author = new ApplicationUser { UserName = "Homer" },
                    CreatedAt = new DateTime(2014, 05, this._random.Next(31) + 1)
                }
            ).ToList();
            var dbSet = new MockDbSet<Comment>().SetupSeedData(comments).SetupLinq();
            this._comments.SetupGet(r => r.Query).Returns(dbSet.Object);

            // Act
            Item result = await this._service.GetItemAsync(this._itemId);

            // Assert
            this._comments.VerifyGet(r => r.Query, Times.Once());
            result.Should().NotBeNull();
            result.Comments.Should().NotBeNull();
            result.Comments.ToList().ForEach(c => c.ItemId.Should().Be(this._itemId));
            result.Comments.Should().BeInAscendingOrder(c => c.CreatedAt);
        }

        [TestMethod]
        public async Task GetItemAsync_Should_SetCommentAuthorNameFromAuthor()
        {
            // Arrange
            var item = new Item { Id = this._itemId };
            this._items.Setup(r => r.FindAsync(this._itemId)).ReturnsAsync(item);
            var authorName = "Homer";
            var comment = new Comment
            {
                ItemId = this._itemId,
                Author = new ApplicationUser { UserName = authorName }
            };
            var dbSet = new MockDbSet<Comment>().SetupSeedData(new[] { comment }).SetupLinq();
            this._comments.SetupGet(r => r.Query).Returns(dbSet.Object);

            // Act
            Item result = await this._service.GetItemAsync(this._itemId);

            // Assert
            result.Comments.Should().HaveCount(1);
            result.Comments.Single().AuthorName.Should().Be(authorName);
        }

        [TestMethod]
        public async Task CreateItemAsync_Should_CallCreateSaveChangesAsync()
        {
            // Arrange
            var item = new Item { Id = this._itemId };

            // Act
            Item result = await this._service.CreateItemAsync(item);

            // Assert
            result.Should().Be(item);
            this._items.Verify(r => r.Create(item), Times.Once());
            this._unitOfWork.Verify(r => r.SaveChangesAsync(), Times.Once());
        }

        [TestMethod]
        public async Task UpdateItemAsync_Should_CallUpdateSaveChangesAsyncIfExists()
        {
            // Arrange
            var item = new Item { Id = this._itemId };
            var dbSet = new MockDbSet<Item>().SetupSeedData(new[] { item }).SetupLinq();
            this._items.SetupGet(r => r.Query).Returns(dbSet.Object);
            this._unitOfWork.Setup(r => r.SaveChangesAsync()).Returns(Task.FromResult(1));

            // Act
            Item result = await this._service.UpdateItemAsync(item);

            // Assert
            result.Should().Be(item);
            this._items.Verify(r => r.Update(item), Times.Once());
            this._unitOfWork.Verify(u => u.SaveChangesAsync(), Times.Once());
        }

        [TestMethod]
        public async Task UpdateItemAsync_Should_ReturnNullIfNotExists()
        {
            // Arrange
            var item = new Item { Id = this._itemId };
            var dbSet = new MockDbSet<Item>().SetupSeedData(Enumerable.Empty<Item>()).SetupLinq();
            this._items.SetupGet(r => r.Query).Returns(dbSet.Object);
            this._unitOfWork.Setup(r => r.SaveChangesAsync()).Returns(Task.FromResult(1));

            // Act
            Item result = await this._service.UpdateItemAsync(item);

            // Assert
            result.Should().BeNull();
            this._items.Verify(r => r.Update(item), Times.Never());
            this._unitOfWork.Verify(r => r.SaveChangesAsync(), Times.Never());
        }

        [TestMethod]
        public async Task DeleteItemAsync_Should_CallFindAsync()
        {
            // Arrange

            // Act
            Item result = await this._service.DeleteItemAsync(this._itemId);

            // Assert
            this._items.Verify(r => r.FindAsync(this._itemId), Times.Once());
        }

        [TestMethod]
        public async Task DeleteItemAsync_Should_CallRemoveSaveChangesAsyncIfExists()
        {
            // Arrange
            var item = new Item { Id = this._itemId };
            this._items.Setup(r => r.FindAsync(this._itemId)).ReturnsAsync(item);
            this._unitOfWork.Setup(r => r.SaveChangesAsync()).Returns(Task.FromResult(1));

            // Act
            Item result = await this._service.DeleteItemAsync(this._itemId);

            // Assert
            this._items.Verify(r => r.Remove(item), Times.Once());
            this._unitOfWork.Verify(u => u.SaveChangesAsync(), Times.Once());
        }

        [TestMethod]
        public async Task DeleteItemAsync_Should_ReturnNullIfNotExists()
        {
            // Arrange
            this._items.Setup(r => r.FindAsync(this._itemId)).ReturnsAsync(null);

            // Act
            Item result = await this._service.DeleteItemAsync(this._itemId);

            // Assert
            result.Should().BeNull();
            this._unitOfWork.Verify(u => u.SaveChangesAsync(), Times.Never());
        }

        [TestMethod]
        public async Task CreateCommentAsync_Should_ReturnNullIfNotExists()
        {
            // Arrange
            var dbSet = new MockDbSet<Item>().SetupSeedData(Enumerable.Empty<Item>()).SetupLinq();
            this._items.SetupGet(r => r.Query).Returns(dbSet.Object);
            this._items.Setup(r => r.FindAsync(this._itemId)).ReturnsAsync(null);

            // Act
            Comment result = await this._service.CreateCommentAsync(this._itemId, this._userId, "D'oh!");

            // Assert
            result.Should().BeNull();
            this._unitOfWork.Verify(u => u.SaveChangesAsync(), Times.Never());
        }

        [TestMethod]
        public async Task CreateCommentAsync_Should_CallCreateSaveChangesAsyncIfExists()
        {
            // Arrange
            var item = new Item { Id = this._itemId };
            var dbSet = new MockDbSet<Item>().SetupSeedData(new[] { item }).SetupLinq();
            this._items.SetupGet(r => r.Query).Returns(dbSet.Object);

            // Act
            Comment result = await this._service.CreateCommentAsync(this._itemId, this._userId, "D'oh!");

            // Assert
            this._comments.Verify(r => r.Create(result), Times.Once());
            this._unitOfWork.Verify(u => u.SaveChangesAsync(), Times.Once());
        }

        [TestMethod]
        public async Task CreateCommentAsync_Should_ReturnCommentIfExists()
        {
            // Arrange
            var item = new Item { Id = this._itemId };
            var dbSet = new MockDbSet<Item>().SetupSeedData(new[] { item }).SetupLinq();
            this._items.SetupGet(r => r.Query).Returns(dbSet.Object);
            var content = "D'oh!";
            var now = DateTime.Now;

            // Act
            Comment result = await this._service.CreateCommentAsync(this._itemId, this._userId, content);

            // Assert
            result.Should().NotBeNull();
            result.ItemId.Should().Be(this._itemId);
            result.AuthorId.Should().Be(this._userId);
            result.Content.Should().Be(content);
            (result.CreatedAt - now).TotalMilliseconds.Should().BeInRange(0, 10);
        }

        [TestMethod]
        public void Dispose_Should_CallUnitOfWorkDispose()
        {
            // Arrange

            // Act
            this._service.Dispose();

            // Assert
            this._unitOfWork.Verify(u => u.Dispose(), Times.Once());
        }
    }
}

ItemService

using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using Commerce.DataAccess;
using Commerce.Models;

namespace Commerce.Services
{
    public class ItemService : IDisposable
    {
        private IUnitOfWork _unitOfWork;
        private IRepository<Item> _items;
        private IRepository<Comment> _comments;

        public ItemService(IUnitOfWork unitOfWork)
        {
            this._unitOfWork = unitOfWork;
            this._items = unitOfWork.GetRepository<Item>();
            this._comments = unitOfWork.GetRepository<Comment>();
        }

        public async Task<IEnumerable<Item>> GetItemsAsync()
        {
            return await this._items.Query.ToListAsync();
        }

        public async Task<Item> GetItemAsync(long id)
        {
            Item item = await this._items.FindAsync(id);
            var query = from c in this._comments.Query
                        where c.ItemId == id
                        orderby c.CreatedAt
                        select new
                        {
                            Comment = c,
                            AuthorName = c.Author.UserName
                        };
            List<Comment> comments = new List<Comment>();
            await query.ForEachAsync(e =>
            {
                Comment comment = e.Comment;
                comment.AuthorName = e.AuthorName;
                comments.Add(comment);
            });
            item.Comments = comments;
            return item;
        }

        public async Task<Item> CreateItemAsync(Item item)
        {
            this._items.Create(item);
            await this._unitOfWork.SaveChangesAsync();
            return item;
        }

        public async Task<Item> UpdateItemAsync(Item item)
        {
            if (false == await this._items.Query.AnyAsync(e => e.Id == item.Id))
            {
                return null;
            }
            this._items.Update(item);
            await this._unitOfWork.SaveChangesAsync();
            return item;
        }

        public async Task<Item> DeleteItemAsync(long id)
        {
            Item item = await this._items.FindAsync(id);
            if (item == null)
            {
                return null;
            }
            this._items.Remove(item);
            await this._unitOfWork.SaveChangesAsync();
            return item;
        }

        public async Task<Comment> CreateCommentAsync(long itemId, string authorId, string content)
        {
            if (false == await this._items.Query.AnyAsync(e => e.Id == itemId))
            {
                return null;
            }
            Comment comment = new Comment
            {
                ItemId = itemId,
                AuthorId = authorId,
                Content = content,
                CreatedAt = DateTime.Now
            };
            this._comments.Create(comment);
            await this._unitOfWork.SaveChangesAsync();
            return comment;
        }

        public void Dispose()
        {
            this._unitOfWork.Dispose();
        }
    }
}

모든 단위 테스트가 성공합니다. 기분이 더 좋습니다. 완벽하다고 단정짓는 것은 매우 위험하지만 비즈니스 계층에 대한 신뢰도가 한결 높아졌습니다.

passed-tests

DbContextRepository<> 클래스와 DbContextUnitOfWork<> 클래스

ItemService 클래스가 모두 구현되었지만 아직 제대로 기능할 수 없습니다. ItemService가 의존하는 IRepository<>IUnitOfWork 인터페이스에 대한 프로덕션 구현이 아직 없기 때문입니다. 테스트 시에는 Moq과 EntityFramework.Testing.Moq의 도움을 받아 모의 개체(테스트 더블)를 만들어 사용했죠. 이제 실제로 동작하는 데이터 접근 계층을 만듭니다.

DbContextRepository<>

using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;

namespace Commerce.DataAccess
{
    public class DbContextRepository<TEntity> : IRepository<TEntity>
        where TEntity : class
    {
        private DbContext _dbContext;
        private DbSet<TEntity> _dbSet;

        public DbContextRepository(DbContext dbContext)
        {
            this._dbContext = dbContext;
            this._dbSet = dbContext.Set<TEntity>();
        }

        public Task<TEntity> FindAsync(params object[] keyValues)
        {
            return this._dbSet.FindAsync(keyValues);
        }

        public IQueryable<TEntity> Query
        {
            get
            {
                return this._dbSet;
            }
        }

        public void Create(TEntity entity)
        {
            this._dbSet.Add(entity);
        }

        public void Update(TEntity entity)
        {
            this._dbContext.Entry(entity).State = EntityState.Modified;
        }

        public void Remove(TEntity entity)
        {
            this._dbSet.Remove(entity);
        }
    }
}

DbContextUnitOfWork<>

using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Threading.Tasks;

namespace Commerce.DataAccess
{
    public class DbContextUnitOfWork<TDbContext> : IUnitOfWork
        where TDbContext : DbContext, new()
    {
        private TDbContext _dbContext;
        private Dictionary<Type, object> _repositories;

        public DbContextUnitOfWork()
        {
            this._dbContext = new TDbContext();
        }

        public IRepository<TEntity> GetRepository<TEntity>() where TEntity : class
        {
            if (this._repositories == null)
            {
                this._repositories = new Dictionary<Type, object>();
            }
            object repository;
            if (this._repositories.TryGetValue(typeof(TEntity), out repository) == false)
            {
                repository = new DbContextRepository<TEntity>(this._dbContext);
                this._repositories[typeof(TEntity)] = repository;
            }
            return (IRepository<TEntity>)repository;
        }

        public Task SaveChangesAsync()
        {
            return this._dbContext.SaveChangesAsync();
        }

        public void Dispose()
        {
            this._dbContext.Dispose();
        }
    }
}

두 클래스 모두 DbContext를 기반으로 동작하며 매우 단순합니다. 어려운 일들은 Entity Framework가 다 해주니까요. 실제 개발에는 IoC 컨테이너 프레임워크를 사용하겠지만 설명을 간단히 하기 위해 ItemService 클래스에 기본 생성자를 추가해 프로덕션 환경에서 IUnitOfWork의 구현체로 DbContextUnitOfWork<ApplicationDbContext> 인스턴스를 사용하도록 합니다.

public ItemService()
    : this(new DbContextUnitOfWork<ApplicationDbContext>())
{
}

완료된 전체 코드는 여기에서 확인할 수 있습니다.

결론

다중 계층 아키텍처 디자인은 코드를 좀 더 단순하고 주요 역할에 집중할 수 있도록 도와줍니다. 여기에 더해 비즈니스 논리 코드에 대해 데이터 접근 계층 독립적인 단위 테스트를 작성하고 TDD나 BDD를 적용하려면 계층 사이의 약한 결합을 구현해야 하는데 Repository and Unit Of Work 디자인 패턴이 좋은 해결책입니다. 물론 이 포스트에서 보여진 구현이 유일한 방법은 아니며 각 상황에 따라 적절한 방법을 고민해야 합니다.

Advertisements

Repository and Unit of Work 디자인 패턴을 이용한 TDD(Test-driven Development)”에 대한 2개의 생각

  1. jmLee

    좋은글 감사합니다. UnitOfWork pattern으로 검색해서 나오는 유일한(?) 한글문서였는데 너무 유익했습니다. 블로그의 다른 글들도 너무 좋네요.

    응답

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Google+ photo

Google+의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

%s에 연결하는 중