032. (Unit Test Principles) 6. 단위 테스트 스타일

6. 단위 테스트 스타일

원제 : Styles of unit testing

이번 포스팅에서는 단위 테스트 스타일에 대한 동일한 틀을 적용해보는 방법에 대해 알아본다.

단위 테스트는 크게 3가지 스타일을 가지고 있으며 각각 출력 기반 스타일, 상태 기반 스타일, 통신 기반 스타일로 분류된다.

출력 기반 스타일이 최선, 상태 기반 테스트는 차선이며, 통신 기반 스타일은 간헐적으로만 사용해야 한다.

다만 출력 기반 스타일은 순수 함수 방식으로 작성된 코드에서만 적용되므로, 출력 기반 스타일로 변환하는 기법들을 알아둘 필요가 있다.

6.1. 단위 테스트의 세 가지 스타일

원제 : The three styles of unit testing

상술했듯 단위 테스트는 크게 세 가지 스타일이 있다.

  • 출력 기반 테스트(output-based testing)
  • 상태 기반 테스트(state-based testing)
  • 통신 기반 테스트(communication-based testing)

하나의 테스트당 최소 하나부터 세 가지 스타일을 모두 사용할 수도 있다.

각 스타일을 하나씩 정의해보자.

6.1.1. 출력 기반 테스트 정의

원제 : Defining the output-based style

먼저 출력 기반 스타일이다.

테스트 대상 시스템(SUT)에 입력을 넣고 이후 생성되는 출력을 점검하는 방식이다.

이 스타일은 전역적인 스코프를 가진 상태나 내부 상태를 변경하지 않는 코드에만 적용할 수 있으므로, 반환 값만 검증하면 된다는 장점이 있다.

Figure 6.1

위 그림처럼 SUT에 입력이 주어졌을 때, 내부에서 어떠한 동작을 하든 SUT의 결과만이 검증 대상이 된다.

이번엔 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class PriceEngine {
public decimal CalculateDiscount(params Product[] products) {
decimal discount = products.Length * 0.01m;
return Math.Min(discount, 0.2m);
}
}

[Fact]
public void Discount_of_two_products() {
var product1 = new Product("Hand wash");
var product2 = new Product("Shampoo");
var sut = new PriceEngine();
decimal discount = sut.CalculateDiscount(product1, product2);
Assert.Equal(0.02m, discount);
}

PriceEngine 클래스는 상품 수에 1%를 곱한 값을 할인율로 반환하되, 최대 할인율을 20%로 제한하는 역할을 한다.

동작한 내부의 상태가 변경되거나, 외부 저장소에 저장하는 것도 없음을 확인할 수 있다.

CalculateDiscount 메서드의 결과는 반환된 할인율 값 뿐이다.

이 예제코드를 위의 그림에 적용하면 아래와 같다.

Figure 6.2

예제를 기반으로 출력 값만 검증하면 되는 코드에 대해 어느 정도 이해했을 것이다.

이러한 스타일을 함수형(funtional) 이라고도 하며, 부작용이 없는 코드를 강조하는 함수형 프로그래밍이 어떤 의미인지도 유추할 수 있다.

포스팅 후반부에 함수형 프로그래밍과 함수형 아키텍쳐에 다시 다루도록 하겠다.

6.1.2. 상태 기반 스타일 정의

원제 : Defining the state-based style

상태 기반 스타일은 작업이 완료된 후 시스템의 상태를 확인하는 것이다.

여기서 상태란 SUT나 협력자 중 하나 또는 데이터베이스나 파일 시스템과 같은 프로세스 외부 의존성의 상태 등을 의미한다.

Figure 6.3

위의 그림처럼 동작 이후 제품 코드 내의 여러 상태들을 검증하는 방식이다.

예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Order {
private readonly List<Product> _products = new List<Product>();
public IReadOnlyList<Product> Products => _products.ToList();

public void AddProduct(Product product) {
_products.Add(product);
}
}

[Fact]
public void Adding_a_product_to_an_order() {
var product = new Product("Hand wash");
var sut = new Order();
sut.AddProduct(product);
Assert.Equal(1, sut.Products.Count);
Assert.Equal(product, sut.Products[0]);
}

테스트 코드를 보면 Product를 하나 추가한 뒤 sut.Product를 통해 Order 내 Product List를 검증한다.

위의 정의대로 출력값이 아닌 AddProduct 함수의 호출 이후의 Order의 변경된 상태를 검증하고 있다.

6.1.3. 통신 기반 스타일 정의

원제 : Defining the communication-based style

마지막으로 통신 기반 스타일을 정의해보자.

이 스타일은 아래 그림처럼 Mock을 사용해 테스트 대상 시스템과 협력자 간의 통신을 검증한다.

Figure 6.3

예제도 살펴보자.

1
2
3
4
5
6
7
8
9
[Fact]
public void Sending_a_greetings_email() {
var emailGatewayMock = new Mock<IEmailGateway>();
var sut = new Controller(emailGatewayMock.Object);
sut.GreetUser("user@email.com");
emailGatewayMock.Verify(
x => x.SendGreetingsEmail("user@email.com"),
Times.Once);
}

6.2. 단위 테스트 스타일 비교

원제 : Comparing the three styles of unit testing

좋은 단위 테스트의 4대 요소를 다시 리마인드해보자.

  • 회귀 방지(Protection against regressions)
  • 리팩토링 내성(Resistance to refactoring)
  • 빠른 피드백(Fast feedback)
  • 유지보수성(Maintainability)

이 4대 요소를 단위 테스트 스타일에 빗대어 살펴보도록 하자.

참고 030. (Unit Test Principles) 4. 좋은 단위 테스트의 4대 요소

6.2.1. 회귀 방지와 피드백 속도 측면에서의 스타일 비교

원제 : Comparing the styles using the metrics of protection against regressions and feedback speed

먼저 회귀 방지 측면에서 세 가지 스타일을 비교해보자.

회귀 방지 지표는 아래 세 가지 특성으로 결정된다.

  1. 테스트 중에 실행되는 코드의 양
  2. 코드 복잡도
  3. 도메인 유의성

어떤 스타일을 적용하더라도 실행하는 코드의 양과 상관없이 작성할 수 있다.

코드 복잡도와 도메인 유의성도 마찬가지인데, 통신 기반 테스트의 경우 일부의 코드 조각만 검증하고 나머지는 전부 Mock으로 대체되는 피상적인 테스트가 될 수도 있다.

다만 피상적인 테스트의 도출을 통신 기반 테스트의 특징이 아닌 기술을 남용하는 극단적인 사례임을 알아야 한다.

다음으로 피드백 속도이다.

피드백의 속도와 단위 테스트 스타일은 딱히 상관 관계가 없다.

테스트가 프로세스 외부 의존성과 떨어져서 단위 테스트 영역에 존재하는 한, 모든 테스트 스타일은 테스트 속도가 거의 비슷할 것이기 때문이다.

6.2.2. 리팩토링 내성 측면에서의 스타일 비교

원제 : Comparing the styles using the metric of resistance to refactoring

회귀 방지와 피드백 속도와 달리 리팩토링 내성은 스타일에 따른 상관 관계가 존재한다.

리팩토링 내성은 리팩토링 중 발생하는 허위 정보인 거짓 양성 수에 따른 척도이다.

결과적으로 거짓 양성은 식별할 수 있는 동작이 아닌 코드의 구현 세부 사항에 결합된 테스트의 결과임을 우리는 이미 알고 있다.

이 상태에서 단위 테스트 스타일들을 바라보자.

출력 기반 테스트는 테스트가 테스트 대상 메서드에만 결합되므로 거짓 양성 방지가 가장 우수하다.

상태 기반 테스트는 일반적으로 거짓 양성이 되기 쉬운데, 이는 테스트 대상 메서드 외에도 테스트 대상인 클래스의 상태와 묶여서 동작하기 때문이다.

확률적으로 테스트와 제품 코드간의 결합이 강할 수록 유출되는 구현 세부 사항에 테스트가 얽매일 가능성이 커지는데,

상태 기반 테스트는 큰 API 노출 영역에 의존하므로 구현 세부 사항과 결합할 가능성도 덩달아 높아진다.

통신 기반 테스트는 거짓 양성에 가장 취약하다.

테스트 대역을 통해 상호 작용을 확인하는 테스트는 대부분 깨지기 쉽고, 이는 항상 Stub과 상호작용하는 경우이다.

따라서 통신 기반 테스트 스타일을 적용할 때는 리팩토링 내성을 지키기 위해 좀 더 신중해줄 필요가 있다.

6.2.3. 유지보수성 측면에서의 스타일 비교

원제 : Comparing the styles using the metric of maintainability

마지막으로 유지보수성 지표다.

유지보수성은 단위 테스트 스타일과 밀접한 관련이 있다.

다만 리팩토링 내성과 달리 완화할 수 있는 방법이 많지 않는 것이 문제이다.

유지보수성은 단위 테스트의 유지비를 측정하며, 다음 두 가지 특성으로 정의한다.

  • 테스트를 이해하기 얼마나 어려운가? (테스트 크기에 대한 함수)
  • 테스트를 실행하기 얼마나 어려운거? (테스트에 직접적으로 관련있는 프로세스 외부 의존성 개수에 대한 함수)

테스트가 비대하면 필요할 때 파악하기도, 변경하기도 어려운 것이 당연하므로 유지보수성 또한 떨어지게 된다.

마찬가지로 통신 기반 스타일처럼 하나 이상의 프로세스 외부 의존성과 직접 작동하는 테스트는 운영하는 데 시간이 필요하므로 유지보수성이 떨어지게 된다.

그나마 출력 기반 테스트가 짧고 간결하므로 가장 유지보수성이 좋다.

또한 출력 기반 테스트는 전역 상태나 내부 상태를 변경하지 않으므로 프로세스 외부 의존성의 제약에서도 벗어날 수 있다.

상태 기반 테스트는 출력 기반 테스트보다 유지보수가 어려운 것이 일반적이다.

이는 출력 검증보다 상태 검증이 조금 더 비대한 코드가 필요하기 때문이다.

아래 상태 기반 테스트의 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
[Fact]
public void Adding_a_comment_to_an_article() {
var sut = new Article();
var text = "Comment text";
var author = "John Doe";
var now = new DateTime(2019, 4, 1);
sut.AddComment(text, author, now);
Assert.Equal(1, sut.Comments.Count);
Assert.Equal(text, sut.Comments[0].Text);
Assert.Equal(author, sut.Comments[0].Author);
Assert.Equal(now, sut.Comments[0].DateCreated);
}

이 테스트는 글에 댓글을 추가한 뒤 댓글 목록에 댓글이 나타나는 지 검증한다.

글에 댓글은 단 하나만 추가했지만 이를 검증 하기 위해선 코드가 네 줄이 필요한 상황이 되었다.

상태 기반 테스트는 위 예제보다 훨씬 많은 데이터를 확인해야하므로 코드가 급증할 가능성이 높다.

이를 좀 더 개선해보자.

1
2
3
4
5
6
7
8
9
10
[Fact]
public void Adding_a_comment_to_an_article() {
var sut = new Article();
var text = "Comment text";
var author = "John Doe";
var now = new DateTime(2019, 4, 1);
sut.AddComment(text, author, now);
sut.ShouldContainNumberOfComments(1)
.WithComment(text, author, now);
}

위와 같이 헬퍼 메서드 등을 추가하여 검증에 필요한 코드를 줄일 수는 있다.

다만 이러한 메서드를 작성하고 유지보수하는 데에 또 다른 노력과 비용이 리소스로 필요해진다.

이 리소스를 투자하기 위해 작성하려는 헬퍼 메서드가 여러 곳에서 재사용된다는 명분이 있으면 좋겠지만, 그런 경우는 사실 드물다.

헬퍼 메서드 외에도 상태 기반 테스트를 좀 더 단축하기 위한 동등 멤버를 정의하는 방법도 있다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
[Fact]
public void Adding_a_comment_to_an_article() {
var sut = new Article();
var comment = new Comment(
"Comment text",
"John Doe",
new DateTime(2019, 4, 1)
);

sut.AddComment(comment.Text, comment.Author, comment.DateCreated);

sut.Comments.Should().BeEquivalentTo(comment);
}

다만 헬퍼 메서드가 동등 멤버를 통한 값 객체 변환 등은 간헐적으로 적용할 수 있는 기법이며,

적용할 수 있더라도 상태 기반 테스트는 출력 기반 테스트보다 더 많은 영역을 차지하는 것은 변하지 않는다.

통신 기반 테스트는 테스트 대역과 상호 작용 검증을 설정해야 하므로 더 많은 코드를 작성해야하므로 제일 유지보수성이 떨어진다고 볼 수 있다.

6.2.4. 스타일 비교의 결론

원제 : Comparing the styles: The results

마지막으로 좋은 단위 테스트의 특성 측면에서 단위 테스트 스타일을 비교해보자.

구분 출력 기반 스타일 상태 기반 스타일 통신 기반 스타일
회귀 방지 동일 동일 동일
피드백 속도 동일 동일 동일
리팩토링 내성 낮음 중간 중간
유지보수성 낮음 중간 높음

회귀 방지와 피드백 속도는 세 가지 스타일 모두가 동일하다.

제일 처음 언급했듯 출력 기반 테스트가 가장 결과가 좋은 것을 알 수 있다.

6.3. 함수형 아키텍쳐의 이해

원제 : Understanding functional architecture

가장 좋은 스타일인 출력 기반 테스트를 적용하기 위해 코드를 순수 함수로 만들어야 한다.

이 순수 함수로 만드는 방법을 알기전에 약간의 선행 지식이 요구된다.

따라서 이번엔 함수형 프로그래밍과 함수형 아키텍쳐가 무엇인지 알아보고 예제를 통해 이해해보도록 하자.

6.3.1. 함수형 프로그래밍이란 무엇인가?

원제 : What is functional programming?

상술했듯 출력 기반 단위 테스트 스타일은 함수형이라고도 부른다.

테스트 대상 코드를 함수형 프로그래밍을 이용해 순수 함수 방식으로 작성해야 하기 때문이다.

그렇다면 함수형 프로그래밍이란 무엇일까?

함수형 프로그래밍은 수학적 함수(mathematical function) 을 사용한 프로그래밍이다.

참고 수학적 함수를 다른 말로 순수 함수(pure function) 이라고도 한다.

수학적 함수는 숨은 입출력이 없는 함수 또는 메서드를 의미하며, 수학적 함수의 모든 입출력은 메서드명, 인수, 반환 타입으로 구성된 메서드 시그니쳐에 명시해야 한다.

또한 수학적 함수는 호출 횟수에 상관없이 주어진 입력에 대해 동일한 출력을 생성한다.

아래 예제를 보자.

1
2
3
4
public decimal CalculateDiscount(Product[] products) {
decimal discount = products.Length * 0.01m;
return Math.Min(discount, 0.2m);
}

이 메서드는 Product[] 타입의 입력 하나와 deicmal 타입의 출력 하나가 존재하며, 둘 다 메서드 시그니처에 명시되어 있다.

따라서 CalculateDiscount 함수는 수학적 함수라고 볼 수 있다.

참고 수학에서 함수란 첫 번째 집합의 각 요소에 대해 두 번째 집합에서 정확히 하나의 요소를 찾는 두 집합 사이의 관계를 뜻한다는 것을 상기해보자.

만약 CalculateDiscount 함수를 집합 관계로 나타내면 아래 그림과 같이 도식될 것이다.

Figure 6.7

이처럼 입출력을 명시한 수학적 함수는 테스트가 짧고 간결하며 이해하기 쉽고 유지보수성 또한 뛰어나다.

특히 출력 기반 테스트를 적용할 수 있는 메서드 유형은 수학적 함수뿐이므로 거짓 양성의 빈도 또한 낮출 수 있게 된다.

반대로 입출력이 숨어있게 된다면 코드를 테스트하기 힘들뿐만 아니라 가독성도 떨어지게 된다.

숨은 입출력의 유형은 다음과 같다.

1. 부작용(Side effects)

메서드 시그니처에 표시되지않는 출력을 의미한다.

이 때의 작업은 객체의 상태를 변경하거나 디스크의 파일을 업데이트하는 등의 부작용을 발생시킨다.

2. 예외(Exceptions)

메서드가 예외를 발생시키는 경우, 이 예외는 호출 스택의 어느 곳에서도 발생할 수 있으므로 메서드 시그니처에 명시되지않은 출력이 발생한다.

3. 내외부 상태에 대한 참조(A reference to an internal or external state)

정적인 속성을 사용하는 메서드와 같이 데이터베이스에 쿼리하거나 비공개인 필드를 참조 하는 등 메서드 시그니처에 없는 흐름에 대한 입력을 수행하는 경우를 말한다.


작성한 메서드가 수학적 함수인지 판단하는 가장 좋은 방법은 프로그램의 동작을 변경하지않고 해당 메서드에 대한 호출을 반환 값으로 대체할 수 있는 지 확인하는 것이다.

이러한 방식을 참조 투명성(referential transparency) 이라고 부른다. 아래 예제를 보자.

1
2
3
public int Increment(int x) {
return x + 1;
}

이 메서드는 수학적 함수로 아래 두 코드는 서로 동일하다.

1
2
int y = Increment(4);
int y = 5;

반면에 아래 메서드는 수학적 함수가 아니다.

1
2
3
4
5
int x = 0;
public int Increment() {
x++;
return x;
}

그 이유는 반환 값이 메서드의 출력을 모두 나타내지 않으므로 반환값을 대체할 수 없기 때문이다.

여기서의 숨은 출력(여기서는 부작용)은 필드 x의 변경이다.

이렇듯 부작용은 숨은 출력의 가장 일반적인 유형이다.

아래 예제는 겉으로는 수학적 함수처럼 보이지만 실제로는 수학적 함수는 아닌 코드이다.

1
2
3
4
5
public Comment AddComment(string text) {
var comment = new Comment(text);
_comments.Add(comment);
return comment;
}

주어진 입력 text를 이용해 comment를 초기화하고 이를 반환하고 있지만 _comments.Add(comment)라는 숨은 출력을 가지고 있음을 알 수 있다.

6.3.2. 함수형 아키텍쳐란 무엇인가?

원제 : What is functional architecture?

당연하게도 부작용이 모두 제거된 애플리케이션은 만들 수 없는 유토피아에 가깝다.

따라서 함수형 프로그래밍의 목표는 부작용을 완전히 제거하는 것이 아니라 비즈니스 로직을 처리하는 코드와 부작용을 일으키는 코드를 분리하는 것이다.

비즈니스 로직 처리에 대한 책임과 부작용을 일으키는 코드의 책임을 모두 고려하면 복잡도가 커지고 장기적으로 코드의 유지보수성을 낮추게 되는데, 바로 이곳이 함수형 아키텍처를 적용하는 지점이다.

함수형 아키텍쳐는 부작용을 비즈니스 로직 동작의 끝으로 몰아서 비즈니스 로직과 부작용을 분리한다.

함수형 아키텍처 적용을 위해 크게 두 가지로 코드 유형을 구분할 수 있다.

  • 결정을 내리는 코드 는 부작용이 필요없기 때문에 수학적 함수를 이용해서 작성할 수 있다.
  • 해당 결정에 따라 작용하는 코드 는 수학적 함수에 의해 이뤄진 모든 결정을 가시적인 부분으로 변환한다.

여기서 결정을 내리는 코드는 함수형 코어(functional core) 혹은 불변 코어(immutable core) 라고도 하며, 결정에 따라 작용하는 코드는 가변 셸(mutable shell) 이라고도 부른다.

Figure 6.9

함수형 코어와 가변 셸은 아래와 같은 방식으로 협력한다.

  • 가변 셸은 모든 입력을 수집한다.
  • 함수형 코어는 결정을 생성한다.
  • 가변 셸은 결정을 부작용으로 변환한다.

이 두 계층을 잘 분리하려면 가변 셸이 의사결정을 추가하지않게끔 결정을 나타내는 클래스에 정보가 충분히 있는 지 확인해야한다.

이때의 목표는 함수형 코어에 대해 출력 기반 테스트로 커버하고, 가변 셸은 훨씬 더 적은 수의 통합 테스트를 수행하게 하는 것이다.

6.4. 함수형 아키텍처와 출력 기반 테스트로의 전환

원제 : Transitioning to functional architecture and output-based testing

함수형 아키텍처의 개념에 대해 이해했다면 실제로 함수형 아키텍처로 리팩토링해보도록 하자.

리팩토링은 아래와 같이 크게 두 가지 단계로 나누어 수행한다.

  1. 프로세스 외부 의존성에서 Mock으로 변경
  2. Mock에서 함수형 아키텍처로 변경

이 리팩토링은 테스트 코드에도 영향을 준다.

리팩토링을 수행하며 상태 기반 스타일과 통신 기반 스타일을 출력 기반 스타일로 변환하는 과정을 살펴보도록 하자.

6.4.1. 감사 시스템 소개

원제 : Introducing an audit system

예제로 살펴본 샘플 프로젝트는 감사 시스템이다.

이 감사 시스템은 조직의 모든 방문자를 추적하며, 아래와 같은 구조의 텍스트 파일을 기반 저장소로 사용한다.

1
2
3
4
# audit_01.txt
Peter; 2019-04-06T16:30:00
Jane; 2019-04-06T16:40:00
Jack; 2019-04-06T17:00:00
1
2
3
# audit_02.txt
Mary; 2019-04-06T17:30:00
New Person; Time of visit

이 시스템은 가장 최근 파일의 마지막 줄에 방문자의 이름과 방문 시간을 추가하며, 파일당 최대 항목 수에 도달하면 인덱스를 증가시켜 새로운 파일을 생성한다.

아래 코드는 감사 시스템의 초기 버전 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class AuditManager {
private readonly int _maxEntriesPerFile;
private readonly string _directoryName;

public AuditManager(int maxEntriesPerFile, string directoryName) {
_maxEntriesPerFile = maxEntriesPerFile;
_directoryName = directoryName;
}

public void AddRecord(string visitorName, DateTime timeOfVisit) {
string[] filePaths = Directory.GetFiles(_directoryName);
(int index, string path)[] sorted = SortByIndex(filePaths);

string newRecord = visitorName + ';' + timeOfVisit;

if (sorted.Length == 0) {
string newFile = Path.Combine(_directoryName, "audit_1.txt");
File.WriteAllText(newFile, newRecord);
return;
}

(int currentFileIndex, string currentFilePath) = sorted.Last();
List<string> lines = File.ReadAllLines(currentFilePath).ToList();

if (lines.Count < _maxEntriesPerFile) {
lines.Add(newRecord);
string newContent = string.Join("\r\n", lines);
File.WriteAllText(currentFilePath, newContent);
} else {
int newIndex = currentFileIndex + 1;
string newName = $"audit_{newIndex}.txt";
string newFile = Path.Combine(_directoryName, newName);
File.WriteAllText(newFile, newRecord);
}
}
}

AuditManager는 이 애플리케이션의 주요 클래스로, 파일당 최대 항목 수와 작업 디렉토리를 매개변수로 받는다.

공개 메서드는 AddRecord 뿐이며, 감사 시스템의 모든 작업을 수행한다.

작업 수행 절차는 아래와 같다.

  1. 작업 디렉토리에서 전체 파일 목록을 검색한다.
  2. 인덱스별로 정렬한다.
  3. 아직 감사파일이 없으면 단일 레코드로 첫 번째 파일을 생성한다.
  4. 감사 파일이 있으면 최신 파일을 가져와서 파일의 항목 수가 한계에 도달했는지 검증하고, 검증 결과에 따라 새로운 레코드를 추가하거나 새로운 파일을 생성한다.

AuditManager는 파일 시스템과 밀접한 관계라서 그대로 테스트하기 어려운 상태이다.

테스트를 하려면 테스트 전에 파일을 올바른 위치에 배치하고, 테스트가 끝나면 해당 파일을 읽고 내용을 확인한 후 삭제해야 한다.

Figure 6.12

이 코드를 테스트할때 병목 지점은 파일 시스템이며, 실행 흐름을 방해할 수 있는 공유 의존성이다.

또한 파일 시스템은 테스트를 느리게 하며, 로컬 시스템과 빌드 서버 모두 작업 디렉토리를 가지고 있어야 테스트할 수 있으므로 유지보수성도 저하된다.

표로 정리하면 아래와 같다.

구분 초기 버전 Mock으로의 전환 출력 기반 스타일 적용
회귀 방지 좋음
리팩토링 내성 좋음
빠른 피드백 나쁨
유지보수성 나쁨

한편 파일 시스템에 직접 작동하는 테스트는 단위 테스트의 정의에 맞지 않는다.

  1. 작은 코드 조각(이를 “단위”라고도 함)을 검증하고,
  2. 빠르게 수행하며,
  3. 격리된 방식으로 처리하는 자동화된 테스트

위의 2번, 3번에 부합하지 않으므로 통합 테스트라고 볼 수 있다.

참고 028. (Unit Test Principles) 2. 단위 테스트란 무엇인가

6.4.2. 테스트를 파일 시스템에서 분리하기 위한 Mock 사용

원제 : Using mocks to decouple tests from the filesystem

위 감사 시스템처럼 테스트가 밀접하게 결합된 문제는 일반적으로 팡리 시스템으로 Mock으로 처리해 해결한다.

파일의 모든 연산을 별도의 클래스로 분리하고 AuditManager에 생성자로 해당 클래스를 주입하도록 변경하고, 이 클래스를 Mock으로 처리 후 감사 시스템이 파일에 수행하는 쓰기 동작을 캡쳐한다.

그림으로 나타내면 아래와 같다.

Figure 6.13

Mock은 아래와 같은 인터페이스로 작성한다.

1
2
3
4
5
public interface IFileSystem {
string[] GetFiles(string directoryName);
void WriteAllText(string filePath, string content);
List<string> ReadAllLines(string filePath);
}

이제 실제 코드에 주입해보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class AuditManager {
private readonly int _maxEntriesPerFile;
private readonly string _directoryName;
private readonly IFileSystem _fileSystem; // Mock

public AuditManager(
int maxEntriesPerFile,
string directoryName,
IFileSystem fileSystem) {
_maxEntriesPerFile = maxEntriesPerFile;
_directoryName = directoryName;
_fileSystem = fileSystem;
}
}

public void AddRecord(string visitorName, DateTime timeOfVisit) {
string[] filePaths = _fileSystem // HERE
.GetFiles(_directoryName);
(int index, string path)[] sorted = SortByIndex(filePaths);

string newRecord = visitorName + ';' + timeOfVisit;

if (sorted.Length == 0) {
string newFile = Path.Combine(_directoryName, "audit_1.txt");
_fileSystem.WriteAllText(newFile, newRecord); // HERE
return;
}

(int currentFileIndex, string currentFilePath) = sorted.Last();
List<string> lines = _fileSystem // HERE
.ReadAllLines(currentFilePath);

if (lines.Count < _maxEntriesPerFile) {
lines.Add(newRecord);
string newContent = string.Join("\r\n", lines);
_fileSystem.WriteAllText(currentFilePath, newContent); // HERE
} else {
int newIndex = currentFileIndex + 1;
string newName = $"audit_{newIndex}.txt";
string newFile = Path.Combine(_directoryName, newName);
_fileSystem.WriteAllText(newFile, newRecord); // HERE
}
}

Mock으로의 전환을 통해 AuditManager와 파일 시스템이 서로 분리되어 공유 의존성이 사라지게 되었다.

아래 테스트 코드도 확인해보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[Fact]
public void A_new_file_is_created_when_the_current_file_overflows() {
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock
.Setup(x => x.GetFiles("audits"))
.Returns(new string[]
{
@"audits\audit_1.txt",
@"audits\audit_2.txt"
});

fileSystemMock
.Setup(x => x.ReadAllLines(@"audits\audit_2.txt"))
.Returns(new List<string>
{
"Peter; 2019-04-06T16:30:00",
"Jane; 2019-04-06T16:40:00",
});
var sut = new AuditManager(3, "audits", fileSystemMock.Object);

sut.AddRecord("Alice", DateTime.Parse("2019-04-06T18:00:00"));

fileSystemMock.Verify(x => x.WriteAllText(
@"audits\audit_3.txt",
"Alice;2019-04-06T18:00:00"));
}

이 테스트 코드는 현재 파일의 항목 수가 한계에 도달 했을 때 새로운 파일을 생성하는 지 검증하는 코드로, Mock을 잘 적용했음을 알 수 있다.

다시 점수를 매겨보도록 하자.

구분 초기 버전 Mock으로의 전환 출력 기반 스타일 적용
회귀 방지 좋음 좋음
리팩토링 내성 좋음 좋음
빠른 피드백 나쁨 좋음
유지보수성 나쁨 중간

Mock을 적용한 뒤, 파일 시스템과 분리되어서 빠른 피드백을 얻을 수 있게 되었으며, 파일 시스템의 유지보수도 고려할 필요가 없으므로 유지보수성 또한 좋아졌다.

이제 출력 기반 스타일을 적용하여 좀 더 개선해보도록 하자.

6.4.3. 함수형 아키텍쳐로 리팩토링

원제 : Refactoring toward functional architecture

인터페이스 뒤로 부작용을 숨기고 해당 인터페이스를 AuditManager에 주입하는 대신, 부작용을 완전히 클래스 외부로 옮긴다면 어떻게 될까?

AuditManager는 파일에 수행할 작업을 둘러싼 결정만 책임지게 될 것이다.

새로운 클래스들을 추가로 작성하고 결정에 따라 작용하는 코드를 이전한 뒤의 코드를 살펴보자.

먼저 결정에 필요한 파일 시스템 정보를 가지고 있을 클래스를 작성해보자.

1
2
3
4
5
6
7
8
9
public class FileContent {
public readonly string FileName;
public readonly string[] Lines;

public FileContent(string fileName, string[] lines) {
FileName = fileName;
Lines = lines;
}
}

이번엔 작업 디렉토리의 파일을 변경하는 대신 부작용에 대한 처리를 담당할 클래스를 작성해보자.

1
2
3
4
5
6
7
8
9
public class FileUpdate {
public readonly string FileName;
public readonly string NewContent;
public FileUpdate(string fileName, string newContent)
{
FileName = fileName;
NewContent = newContent;
}
}

AuditManager에 적용하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class AuditManager {
private readonly int _maxEntriesPerFile;

public AuditManager(int maxEntriesPerFile) {
_maxEntriesPerFile = maxEntriesPerFile;
}

public FileUpdate AddRecord(
FileContent[] files,
string visitorName,
DateTime timeOfVisit) {

(int index, FileContent file)[] sorted = SortByIndex(files);
string newRecord = visitorName + ';' + timeOfVisit;
if (sorted.Length == 0) {
return new FileUpdate( Returns an update "audit_1.txt", newRecord); // Returns an update instruction
}

(int currentFileIndex, FileContent currentFile) = sorted.Last();
List<string> lines = currentFile.Lines.ToList();

if (lines.Count < _maxEntriesPerFile) {
lines.Add(newRecord);
string newContent = string.Join("\r\n", lines);
return new FileUpdate(currentFile.FileName, newContent); // Returns an update instruction
} else {
int newIndex = currentFileIndex + 1;
string newName = $"audit_{newIndex}.txt";
return new FileUpdate(newName, newRecord);
}
}
}

마지막으로 Persister 클래스를 작성한다.

Persister 클래스는 AuditManager의 결정에 영향을 받는 가변 셸 역할을 수행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Persister {

public FileContent[] ReadDirectory(string directoryName) {
return Directory
.GetFiles(directoryName)
.Select(x => new FileContent(
Path.GetFileName(x),
File.ReadAllLines(x)
))
.ToArray();
}

public void ApplyUpdate(string directoryName, FileUpdate update) {
string filePath = Path.Combine(directoryName, update.FileName);
File.WriteAllText(filePath, update.NewContent);
}
}

이 클래스가 얼마나 간결한지 리뷰해보도록 하자.

작업 디렉토리에서 내용을 읽고 AuditManager에서 받은 업데이트 명령을 작업 디렉토리에 다시 수행하기만 하는 간단한 동작을 하며, 어떠한 분기도 존재하지않는다.

따라서 모든 복잡도는 AuditManager가 가지고 있으며, 비즈니스 로직과 부작용이 분리되었음을 확인할 수 있다.

함수형 아키텍처를 적용한 최종 결과물은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ApplicationService {
private readonly string _directoryName;
private readonly AuditManager _auditManager;
private readonly Persister _persister;

public ApplicationService(string directoryName, int maxEntriesPerFile) {
_directoryName = directoryName;
_auditManager = new AuditManager(maxEntriesPerFile);
_persister = new Persister();
}

public void AddRecord(string visitorName, DateTime timeOfVisit) {
FileContent[] files = _persister.ReadDirectory(_directoryName);
FileUpdate update = _auditManager.AddRecord(files, visitorName, timeOfVisit);
_persister.ApplyUpdate(_directoryName, update);
}
}

함수형 코어와 가변 셸을 연동하면서 작성된 ApplicationService 클래스가 진입점을 제공하고 있으며, 감사 시스템의 동작을 쉽게 확인할 수 있게 되었다.

이제 모든 테스트는 작업 디렉토리의 가상 상태를 제공하고 AuditManager가 내린 결정을 검증하는 것으로 단축되었다.

Figure 6.15

테스트 코드도 확인해보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Fact]
public void A_new_file_is_created_when_the_current_file_overflows() {
var sut = new AuditManager(3);
var files = new FileContent[] {
new FileContent("audit_1.txt", new string[0]),
new FileContent("audit_2.txt", new string[] {
"Peter; 2019-04-06T16:30:00",
"Jane; 2019-04-06T16:40:00",
"Jack; 2019-04-06T17:00:00"
})
};

FileUpdate update = sut.AddRecord(files, "Alice", DateTime.Parse("2019-04-06T18:00:00"));

Assert.Equal("audit_3.txt", update.FileName);
Assert.Equal("Alice;2019-04-06T18:00:00", update.NewContent);
}

리팩토링 후 테스트 코드에서 피드백 속도도 빨라지고 유지보수성 또한 향상 되었음을 알 수 있다.

다시 채점해보자.

구분 초기 버전 Mock으로의 전환 출력 기반 스타일 적용
회귀 방지 좋음 좋음 좋음
리팩토링 내성 좋음 좋음 좋음
빠른 피드백 나쁨 좋음 좋음
유지보수성 나쁨 중간 좋음

또한 함수형 코어가 생성한 명령은 항상 값이거나 값 집합으로, 값의 내용이 일치하는 한 아래와 같이 두 인스턴스를 서로 교체할 수가 있다.