마소에 기고한 TDD와 BDD 내용 중, 코드 작성에 관한 부분을 옮겨 왔다.
그 전에 앞서 약간의 의견을 담아내자면, BDD에 대해서는 다소 회의적이다. 둘 간의 차이가 크지 않고 BDD가 가진 철학이 이미 충분히 TDD에 반영되어 있을 수 있다. 그런 철학은 꼭 BDD만의 것이라고 하기에는 다소 민망하다. 그럼에도 불구하고 차이를 언급했던 것은, 혹시나 차이를 설명하는 과정에서 TDD/BDD에 익숙하지 않은 사람들이 아이디어를 얻어갈 수 있지 않을까 하는 막연한 기대 때문이었다. 아래는 기고 당시의 일부 내용이다.
- 아래 -
이제부터는 간단하게 친구신청/확인 기능을 TDD와 BDD로 작성해보며 이 둘을 살펴보려 한다. 제한된 공간안에 TDD와 BDD를 설명해야 하기 때문에, 바로 구현으로 들어가도 될 만큼 작은 기능을 대상으로 선정했지만, 간단히 기능 정의를 하고 시작하도록 한다.
기능 정의
친구 신청/확인에 필요한 기능은 다음과 같다.
- 사용자는 친구를 맺기 위해, 상대방에게 친구 신청을 할 수 있다.
- 사용자는 다른 사용자와의 친구 여부를 확인할 수 있다.
간단하게 클래스로 만들어 볼 수 있는 대상은 2개 정도가 될 것 같다.
하지만 기능이 크지 않으므로, 사용자 클래스 하나를 두고 이 안에서 친구 신청 및 확인 기능을 함께 구현하도록 한다. 정리하면, 사용자 클래스 하나에 친구신청, 친구확인 기능이 필요하다.
TDD
우선, TDD로 이 내용을 구현해보도록 한다. 가장 먼저 무엇을 테스트해보는게 좋을까? 친구 여부를 확인하기 위해서는 친구 신청을 먼저 구현해야 할 것 같다. 그런데 잠깐, 친구 신청을 했는지 확인하려면 친구 여부 확인이 먼저 필요한 것 아닐까? 이런 경우는 그냥 둘다 함께 확인 가능한 테스트 코드를 작성하면 된다.
먼저 위에서 정의한 기능을 User 클래스의 메서드로 선언한다.
User.java
@Getter
@Setter
public class User {
private String name;
public User(String name) {
// TODO
}
public void sendFriendRequestTo(User receiver) {
// TODO
}
public boolean isFriendWith(User receiver) {
// TODO
}
}
그리고 이를 확인하기 위해 작성한 코드는 다음과 같다.
UserTest.java
public class UserTest {
@Test
public void sendRequestTo() {
// Given
User userA = new User("A");
User userB = new User("B");
// When
userA.sendFriendRequestTo(userB);
// Then
userA.isFriendWith(userB);
}
}
A라는 이름의 사용자와, B라는 이름의 사용자가 전제조건(Given)으로 주어져 있고, A가 B에게 친구 요청을 던진 경우(When), A가 B와 친구가 되었는지를 확인(Then)하고 있다. 이제 User의 내부를 빠르게 구현해보면, 다음과 같은 코드가 나온다.
@Getter
@Setter
public class User {
private String name;
private List<User> friends;
public User(String name) {
this.name = name;
this.friends = new ArrayList<User>();
}
public void sendFriendRequestTo(User receiver) {
friends.add(receiver);
}
public boolean isFriendWith(User receiver) {
return friends.contains(receiver);
}
}
대부분 금방 눈치 챘겠지만, 코드를 보면 B의 입장에서 A가 친구인지의 여부가 의심이 될 것이다. 이런 경우라면 다음과 같이 Test 케이스를 추가해 확인해 볼 수 있다.
UserTest.java
public class UserTest {
... 생략
@Test
public void sendRequestTo_상대방에게_요청을_받은_경우() {
// Given
User userA = new User("A");
User userB = new User("B");
// When
userB.sendFriendRequestTo(userA);
// Then
Assert.assertTrue("친구 요청을 받으면 친구가 되야 한다.", userA.isFriendWith(userB));
}
}
필요하다면 다음과 같이 메서드에 설명을 붙여 케이스를 추가해 나갈 수 있다.
- isFriendWith_친구신청_하지_않은_경우
- sendRequestTo_중복으로_친구신청을_한_경우
- …
그런데 잠깐, 새롭게 추가한 테스트 케이스가 실패함을 확인할 수 있다. 이는 단방향이 아니라 양방향의 관계를 작성하는 것이 익숙치 않은 경우 종종 발생하는 문제이다. User 클래스는 아래와 같이 고쳐서 Test 코드를 성공으로 돌려놓을 수 있다.
User.java
public class User {
... 생략
public void sendFriendRequestTo(User receiver) {
friends.add(receiver);
receiver.getFriends().add(this); // 동기화 코드가 필요함
}
... 생략
}
위의 코드는 친구 중복에 관한 처리 등을 비롯해 테스트 케이스 추가와 코드 보완, 리팩토링이 필요하지만 코드 소개의 목적이 TDD와 BDD의 비교이므로, 일단 이것으로 TDD에 대한 간단한 소개를 마치도록 한다.
BDD
이번에는 똑같은 기능을 BDD로 작성해보도록 하자. BDD를 작성하는데 사용한 프레임웍은 JBehave 이다. 총 5계의 단계를 거쳐 작성을 하게 되는데 이 순서는 다음과 같다.
- 스토리 작성
- 시나리오를 실행시킬 Step 클래스(POJO)를 작성한다.
- Embeddable 클래스를 작성하여 관련된 설정을 지정한다.
- 시나리오를 실행시킨다.
- 결과를 확인한다.
스토리 작성
가장 먼저 스토리를 작성한다. 위에서 정의한 기능을 토대로 다음과 같이 작성해볼 수 있다.
friend_request_story.story
Narrative:
사용자는,
친구를 맺기 위해,
다른 사용자에게 친구 신청을 할 수 있다.
Scenario: 친구 요청
Given ‘A’라는 사용자가 있다.
And ‘B’라는 사용자가 있다.
When 'A'가 'B'에게 친구 신청을 한다.
Then 'A'는 'B'와 친구가 된다.
Scenario: 친구 요청 (받는 경우)
Given ‘A’라는 사용자가 있다.
And ‘B’라는 사용자가 있다.
When 'B'가 'A'에게 친구 신청을 한다.
Then 'A'는 'B'와 친구가 된다.
And ‘B’는 ‘A’와 친구가 된다.
*두꺼운 글씨로 작성된 부분은 꼭 지켜줘야 하는 문법(JBehave 혹은 Grekin Language)이다.
크게 2가지로 구성되는데, Narrative 부분은 테스트 대상이 되는 스토리를 설명하는 부분이다. 이 형식은 애자일 개발 방법에서 많이 사용하는 사용자 스토리(User Story) 작성 템플릿으로, 역할(Role) – 기능(Feature) – 목적(Benefit)의 요소로 구성되어 있다.
- As a [역할]
- I want [기능]
- So that [목적]
위의 형식이 기본 템플릿인데, 한글로 작성하였기 때문에 순서를 바꾸어 작성하였다.
다음으로 Scenario 부분이다. Given에 주어진 조건을 적고, When에 테스트 하고 싶은 어떤 행위를 기술하면 된다. 그리고 마지막 Then에는 기대하는 결과를 작성하면 된다. 기본적인 형식은 다음과 같다.
- Given [주어진 조건]
- And [주어진 다른 조건] …
- When [행위 또는 사건]
- Then [결과]
- And [다른 결과] …
스토리를 작성하는 방법에 대해서 자세히 알고 싶으면 다음의 글을 참조하도록 한다.http://dannorth.net/whats-in-a-story
Step 클래스 작성
friend_request_story와 이름 구조를 맞춰 주어야 하므로, FriendRequestStep이라는 이름으로 다음과 같은 Step클래스를 작성하였다.
FriendRequestStep.java
public class FriendRequestStep {
private UserRepository repository;
@BeforeStories
public void setUp() {
repository = new UserRepository();
}
@Given("'$userName'라는 사용자가 있다.")
public void givenThereIsUser(String userName) {
User user = new User(userName);
repository.save(user);
}
@When("'$senderName'가 '$receiverName'에게 친구 신청을 한다.")
public void whenSendFriendRequest(String senderName, String receiverName) {
User sender = repository.findByName(senderName);
User receiver = repository.findByName(receiverName);
sender.sendFriendRequestTo(receiver);
}
@Then("'$senderName'는 '$receiverName'와 친구가 된다.")
public void thenTheyAreFriend(String senderName, String receiverName) {
User sender = repository.findByName(senderName);
User receiver = repository.findByName(receiverName);
Assert.assertTrue(sender.isFriendWith(receiver));
}
// DB 관련된 사항이 결정되지 않았다고 가정하고 도우미 클래스를 만든다.
private class UserRepository {
private final List<User> userList;
public UserRepository() {
userList = new ArrayList<User>();
}
public void save(User user) {
userList.add(user);
}
public User findByName(String userName) {
for (User user : userList) {
if (user.getName().equals(userName)) {
return user;
}
}
return null;
}
}
}
우선, User 데이터들을 관리하는 모듈이 필요한데, 여기서는 DB Access(DAO, Repository 사용 여부 등) 관련 사항이 결정되지 않아서 UserRepository라는 Mock을 사용하였다. 이런 식으로, 아직 구현되지 않은 모듈이 있는 경우에도, 원래 구현하려던 기능에 집중하여 코드를 작성할 수 있다. 추후 DB관련 사항이 결정되면 UserRepository를 실제 모듈로 대체할 수 있을 것이다.
@BeforeStory 부분은 시나리오를 실행하기 전에 환경적인 부분을 설정하는 곳이다. 물론, 데이터를 미리 설정하는 공간으로 활용할 수도 있지만, 필자는 주로 환경적인 부분을 만드는데 이를 활용하여 @Given과의 용도를 구분한다.
@Given, @When, @Then은 story 파일에서 Given, When, Then을 매핑하는 애노테이션이다. 그리고 각 애노테이션 안에 story의 각 문장을 매핑하는 값들이 있다. 달러($)표시로 시작하는 부분은 story 파일에서 마음대로 값을 지정할 수 있는 부분을 의미한다. 이런 특징으로 인해, 하나의 매핑 메서드에 여러개의 문장을 다양한 값으로 대응시킬 수 있는데, 이는 JBehave가 주는 꽤나 좋은 이점이다. 프레임웍 없이 작성했다고 하면, 우리는 일일이 똑같은 테스트 메서드를 추가하여 그 안에 Given이나 When등을 작성해야 했을 것이다.
실제로 Given에서 표현한 2개의 문장은, 하나의 메서드로 모두 수행 가능하다.
- Step 메서드: givenThereIsUser
- Scenario 문장
- Given ‘A’라는 사용자가 있다.
- And ‘B’라는 사용자가 있다.
만약, 한 명의 사용자를 더 두어 시나리오를 테스트하고 싶거나, 또 다른 시나리오를 추가하려는 경우 이러한 재사용은 점점 이점으로 다가올 것이다. 흔히, Step 클래스는 Story의 Type이며, Story는 이 시스템의 구체적 행위이자 인스턴스라고 표현하기도 한다.
Embeddable 클래스를 작성
여기서 설정 코드를 작성하는 것은 생략하도록 한다. 이 설정에서는 story를 step에 매핑하는 방식이라던지, 결과를 어떤 형태로 보여줄 것인지 등을 결정할 수 있다.
시나리오 실행
JUnit을 통해 우리는 결과를 확인할 수 있는데, 위의 경우 다음과 같은 결과를 확인할 수 있다.
결과 확인
(BeforeStories)Running story friends/friend_request_story.story(friends/friend_request_story.story)Scenario: 친구 요청Given 'Jobs'라는 사용자가 있다.And 'Gates'라는 사용자가 있다.When 'Jobs'가 'Gates'에게 친구 신청을 한다.Then 'Jobs'는 'Gates'와 친구가 된다.
코드를 통해 느껴본 TDD와 TDD
지금까지 친구 신청/확인 기능을 구현하는 TDD와 BDD에 대해서 살펴보았다. 위에서 살펴본 것처럼, TDD와 BDD의 의미 있는 차이라고 한다면, story 파일의 존재이다. 이것은 비 개발자와 소통하는 동시에 시스템의 행위를 보존해주는 도구로 사용될 수도 있다. 분명, 도표나 그림등을 통해서 더 많은 것을 쉽게 표현할 수도 있기 때문에, 시나리오로 모든 것을 표현하는 데에는 한계가 있다. 그럼에도 불구하고 필자는 코드를 작성하기 전에 이런 시나리오들을 간단히 작성해 보는 것을 좋은 개발 시작 지점으로 삼을 수 있었다. JBehave는 이것을 프레임웍에서 지원해주며, 이것이 BDD가 가진 주요 철학 중 하나이다.
더불어, 프레임웍을 통해서 TDD의 반복되는 코드 작업을 줄여주는 이점을 취할 수도 있다. 프레임웍을 사용하지 않는 경우, 여러 케이스를 표현하기 위해 중복되는 코드들이 나오기 마련이다. 이를 SetUp(JUnit에서는 @Before 애노테이션 사용)부분에 넣으면, 반복 작업은 줄지만 가독성이 떨어지고 점점 코드가 복잡해질 여지가 있다. 해서, 어느 정도의 반복작업을 하게 되는 것이다. 그런데 JBehave는 위에서 보듯 Step을 Type으로 작성하여, 여러 시나리오들을 좀 더 수월하게 테스트해볼 수 있었다. 이는 테스트 코드 관리의 부담을 줄여주는 하나의 방법이 될 수 있다고 본다.
그 외에, In-Out 방식과 시나리오 형태로 테스트 주도 개발을 할 수 있다는 점은 BDD(정확히는 JBehave 프레임웍이)가 강제하는 형식이다. 물론, Out-In 방식이 유리한 경우도 있고, 시나리오가 아닌 단순 스펙 확인 방식이 좋은 경우도 있다. BDD 프레임웍이 이런 유연성을 제한하는 것은 분명 한계일 것이다. 그럼에도 불구하고, BDD가 가진 철학들은 한 번은 적용해볼 만한 내용일 것이다.