테스트주도개발 07
Updated:
테스트 주도개발 7장
7.1 일반적인 애플리케이션
7.1.1 생성자 테스트
- 굳이 케이스 작성 필요 없다.
- 다만, 객체 사용을 위해 반드시 갖춰야 하는 값을 생성자에 설정하는 경우는 필요에는 작성
7.1.2 DTO 스타일의 객체 테스트
- 단순 getter, setter로만 이뤄진 DTO는 안한다.
- 특정 목적을 가진 불변책체(immutable object)의 경우에는 getter 계열 메소드나 상태를 확인할 수 있는 is 메소드를 이용해서 작성
7.1.2.1 불변책체
- 한 번 만들어진 다음에, 내부 상태 값을 바꿀 수 없는 객체를 ‘불변 객체’라고 부름.
- 단순하게 setter가 없는 클래스
- 대표적으로 String 클래스
String name = "성윤";
name = "김" + name;
- 사실 위에 코드는 아래 처럼 작동
String name = new String("성윤");
name = new String("김" + name);
7.1.3 닭과 달걀 메소드 테스트
- 메소드가 맞물려 있어서 하나만 독립적으로 테스트 하기 어려운 경우
- 보통 로직 메소드와 상태확인 메소드가 짝을 이룬 경우 해당.
- 예를 들어
public class AttendeeTest {
@Test
public void testAdd() throws Exception {
Attendee attendee = new Attendee();
attendee.add("백기선");
assertEquals("백기선", attendee.get(1));
}
@Test
public void testGet() throws Exception {
Attendee attendee = new Attendee();
attendee.add("백기선");
assertEquals("백기선", attendee.get(1));
}
}
- Attendee 클래스의 add 메소드를 테스트 하기 위해 get 메소드를 써야한다.
- 그런데 get 메소드가 함수가 구현되지 않음.
-
그래서 get 메소드 함수를 테스트 하려고 하는데 이 또한 add가 필요함.
-
원래 규칙은 한 번에 실패하는 테스트 케이스 하나씩 작성 하는 것이 원칙. 그러나 이 경우 힘들다.
- 이럴 때 3가지 경우가 있음
7.1.3.1 실패하는 테스트 케이스가 두 개인 상태에서 작업하기 (일반적)
- 구현 난이도가 있는 메소드의 경우 실패가 계속 된다면 돌이킬수가 없다.
7.1.3.2 안정성이 검증된 제3의 모듈 사용 (가능하다면 권장)
- DbUnit 같은 모듈
- 혹은 테스트 검증이 끝난 메소드 이용
7.1.3.3 자바 리플렉션 이용해 강제 확인 (비권장)
- 비권장이라 정리 안함
7.1.4 배열 테스트
- assertArrayEquals 이용
- assertReflectionEquals나 assertLenientEquals를 이용
- JUnit 3의 경우라면 List로 변환해서 비교
- 위의 경우 다 그 전장에서 설명함
7.1.5 객체 동치성 테스트
- 동일성, 동치성은 6장에 설명함
- 직접 필드 값 비교
- toString 구현해서 비교
- equals 비교
- assertReflectionEquals
7.1.5.1 테스트 수행을 위한 assertEquals 메소드 재정의
- 자주 쓰는 것들은 해놓으면 좋음.
private boolean assertEquals(Car expected, Car actual){
assertEquals(expected.getModelNo(), actual.getModelNo());
assertEquals(expected.getBrandAlias(), actual.getBrandAlias());
assertEquals(expected.getDoorType(), actual.getDoorType());
assertEquals(expected.getPrice(), actual.getPrice());
}
7.1.6 컬렉션 테스트
7.1.6.1 자바 기본형이나 String이 컬렉션이 들어 있는 경우
- 예제
@Test
public void testListEqual_Primitive() {
List<String> listA = new ArrayList<String>();
listA.add("변정훈");
listA.add("조연희");
List<String> listB = new ArrayList<String>();
listB.add("변정훈");
listB.add("조연희");
assertEquals("리스트 비교", listA, listB);
}
7.1.6.2 일반 객체가 컬렉션에 들어 있는 경우
-
toString을 구현해서 비교
-
예제
public class Employee {
private String name;
public Employee(String name) {
this.name = name;
}
@Override
public String toString() {
return this.name;
}
}
@Test
public void testListEqual_Objects() {
List<Employee> listA = new ArrayList<Employee>();
listA.add(new Employee("변정훈"));
listA.add(new Employee("조연희"));
List<Employee> listB = new ArrayList<Employee>();
listB.add(new Employee("변정훈"));
listB.add(new Employee("조연희"));
assertEquals("리스트 비교", listA.toString(), listB.toString());
}
7.2 웹 애플리케이션
7.2.1 MVC 아키텍처
- 설명이 쭉 있음.
7.2.2 뷰 TDD
- HttpUnit는 실질적으로 비효율
- selenium4junit도 ROI(투자 수익률)가 높지 않음
- CubicTest
- 필자는 뷰에 대한 TDD는 결국 비효율적이라고 함. 어떻게든 하라는게 아니라 필요하다면 하는 느낌?
7.2.3 컨트롤러 TDD
- TDD가 수월하다.
- 화면에서 사원번호 입력하면 해당 사원 정보를 출력해주는 예시
package test;
import static org.junit.Assert.assertEquals;
import main.EmployeeSearchServlet;
import org.junit.Test;
import org.springframework.mock.web.*;
public class EmployeeSearchServletTest {
@Test
public void testSearchByEmpid() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(); // ➊
MockHttpServletResponse response = new MockHttpServletResponse(); // ➋
request.addParameter("empid", "5874"); // ➌
EmployeeSearchServlet searchServlet = new EmployeeSearchServlet(); // ➍
searchServlet.service(request, response); // ➎
Employee employee = (Employee)request.getAttribute("employee"); // ➏
assertEquals("박성철", employee.getName() ); // ➐
assertEquals("5874", employee.getEmpid() );
assertEquals("fupfin", employee.getId() );
assertEquals("회장", employee.getPosition() );
assertEquals("/SearchResult.jsp", response.getForwardedUrl()); // ➑
}
}
- 위 테스트를 기반으로 EmployeeSearchServlet를 작성하면 된다.
7.2.3.1 의존성을 줄인 컨트롤러 테스트 케이스 작성
- Mock을 이용해 모델까지 Mock으로 전환
- Mockito와 Unitils를 이용. 모델은 Mockito가 객체 동치성 비교는 assertLenientEquals을 이용
package test;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;
import static org.unitils.reflectionassert.ReflectionAssert.*;
import main.*;
import org.junit.Test;
import org.springframework.mock.web.*;
public class EmployeeSearchServletTest {
@Test
public void testSearchByEmpid() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
request.addParameter("empid", "5874");
SearchBiz biz = mock(SearchBiz.class); // ➊
Employee expectedEmployee = new Employee("박성철","5874", "fupfin","회장"); // ➋
when(biz.getEmployeeByEmpid(anyString())).thenReturn(expectedEmployee); // ➌
EmployeeSearchServlet searchServlet = new EmployeeSearchServlet();
searchServlet.setModel( biz ); // ➍
searchServlet.service(request, response);
Employee employee = (Employee)request.getAttribute("employee");
assertLenientEquals(expectedEmployee, employee); // ➎
assertEquals("/SearchResult.jsp", response. getForwardedUrl());
}
}
7.2.4 모델 TDD
- 도메인 모델, 서비스 모델 두 개로 나뉨.
- 흔히 데이터와 로직이라고 부름
- 도메인 모델로 DTO가 많이 사용되므로 DTO와 Biz라고도 함
7.2.4.1 도메인 모델에 의한 TDD
- 대표적으로 DTO
- TDD를 거의 작성할일이 없음
- 테스트 커버리지율 측면에서 간단히 만드는 것도 나쁘지는 않음
7.2.4.2 서비스 모델에 의한 TDD
-
서비스 모델은 기능 위주로 구성된 클래스
- DTO를 비롯한 여러 다른 도메인 클래스들을 사용하는 애플리케이션의 핵심적인 부분
- MVC 모델에서 TDD로 작성했을 때 가장 빛나는 부분
- 하면 할수록 좋다!
- 단순 화면이나 DB사이의 통로 역할일 경우는 TDD에 대해서 논의해 볼 법함
7.2.4.3 기타 서비스 모델
- 인증, 권한처리 같은 공통사항은 기반이 되는 프레임워크가 처리해준다.
-
모델이 하는일이라고는 SQL과 DAO를 연결해주는 것과 결과 값을 객체로 받는 것이 전부인 서비스 모델이 있다.
- 이런 경우 TDD를 한정적으로 적용
-
DAO를 통해서 넘어온 결과의 건수, 헤더에 해당하는 컬럼이름의 일치 여부 정도를 목표로 TDD 작성
- 모델 테스트가 SQL 정상 작성 테스트에 더 가깝게 변함.
- SQL문을 변경하면 즉시 모델에 에러가 발견
- 가끔 TDD의 의미를 잊고 해더비교하는 부분을 SQL 파일에서 복사한다던가 SQL 실행 결과를 붙이는 경우가 있는데 잘못 된 경우.
- 그건 DAO가 정상 동작하는지 테스트 하는 것.
- TDD의 목표는 처음 목표의 이미지에 도달하도록 구현되었는지 판별하는 것.
- 따라서 헤더 컬럼이름은 화면이나 화면설계서를 보고 assert문을 작성해야 함.
7.2.4.4 DTO, VO, DO, DAO, ENTITY, JavaBean
- 모델은 서비스와 데이터로 나뉜다.
- 서비스에서 사용하는 데이터 객체는 일반적으로 DO(Domain Object)라고 불린다.
- DTO는 데이터 전달을 위해 사용하는 객체. 비즈니스 구현이 없다. 이전에는 VO로 불림
- JavaBean은 재사용을 위해 정의된 자바 컴포넌트. DTO로 많이 사용되는데 반드시 set/get으로 시작하는 이름의 메소드로만 데이터를 접근해야 한다.
- DAO는 DB나 File등과 데이터를 주고 받기 위한 객체. 매개체로 DTO를 사용
- DAO를 서비스에 포함시키는 것이 일반적. 가끔 나누기도 함.
- Entity는 데이터 모델링에서 쓰이는 표현으로 DB 테이블을 말함.
- Entity 클래스라고 불리는 클래스들은 DB의 Entity 클래스 구조를 만들어 놓은 모습. 프로그램으로 구현될 때 DO/DTO 모습을 갖는 것.
7.2.4.5 웹 애플리케이션에 대한 TDD 접근 전략 정리
- 모델과 뷰의 컨트롤러를 최대한 분리시킨다.
- 뷰는 단순히 표현 계층으로 보고 업무로직이 들어가지 않도록 유지한다.
- 뷰에 대한 TDD는 ROI를 잘 따져보고, 필요하다면 TDD를 포기하고 Record & Play 방식의 툴을 사용해 기능 테스트나 회귀 테스트의 비용을 줄이는 쪽으로 생산성을 높이자.
- 컨트롤러가 프레임워크 차원에서 지원될 때는 굳이 테스트 케이스를 만들려고 하지 않는다.
- 모델에 의한 TDD는 최대한 적용한다.
7.3 데이터베이스
- TDD는 흔히 두 가지 경우를 예상. 상태와 동작
- 각각에 대해서 테스트는 어렵지 않으나 섞이면 복잡
- 대표적으로 DB
- 어려운 이유는 다음과 같다.
- 데이터의 상태가 바뀜
- 테스트 전/후의 비교가 쉽지 않음
7.3.1 데이터베이스 상태가 바뀌는 문제의 일반적인 해결 방법
- 모든 방법이 최선은 아니다. 각 문제에 대해서 최대한의 해결을 찾는 것 뿐
7.3.1.1 트랜잭션을 선언하고 테스트 케이스를 수행한 다음 롤백처리
- 트랜젝션 선언만 처리하면 되므로 케이스 작성이 쉬움
- 테스트 케이스 작성 목적이 트랜젝션 처리인 경우 적용 불가
- 트랜젝션을 테스트 내에서 제어할 수 없는 경우도 적용 불가
- @BeforeClass를 이용하여 클래스 단위로 트랜젝션 관리도 할 수 있음
public class DBRepositoryTest {
private final String protocol = "jdbc:derby:";
private final String dbName = "shopdb";
private Connection connection;
private Repository repository;
@Before
public void setUp() throws Exception {
repository = new DatabaseRepository();
connection = ((DatabaseRepository)repository).getConn();
connection.setAutoCommit(false); // ➊
}
@After
public void tearDown() throws Exception {
this.connection.rollback(); // ➋
this.connection.close();
}
@Test
public void testAddNewSeller() throws Exception {
Seller newSeller = new Seller("hssm", "이동욱", "scala@hssm.kr");
repository.add(newSeller);
assertEquals(newSeller, repository.findById("hssm"));
}
}
7.3.1.2 테스트 케이스 작성 시 ‘입력->수정->삭제’ 순서대로 테스트 케이스가 실행되도록 만든다.
- add → findById(=select) → update → remove 순서로 테스트가 진행
- 작성이 쉽다.
- 입력/수정/삭제 중 하나라도 지원되지 않으면 적용 불가능
- TDD는 사실 선후 관계가 존재하도록 케이스 만드는 걸 권장하지 않음. 독립적으로 수행하는 것으 선호
@Test
public void testAddNewSeller() throws Exception {
Seller newSeller = new Seller("hssm", "이동욱", "scala@hssm.kr");
repository.add(newSeller);
assertEquals(newSeller, repository.findById("hssm"));
}
@Test
public void testFindByIdSeller() throws Exception {
Seller expectedSeller = new Seller("hssm", "이동욱", "scala@hssm.kr");
assertEquals(expectedSeller, repository.findById("hssm"));
}
@Test
public void testUpdateSeller() throws Exception {
Seller seller = new Seller("hssm", "이동욱", "scala@ksug.or.kr");
repository.update(seller);
assertEquals(expectedSeller, repository.findById("hssm"));
}
@Test
public void testRemoveSeller() throws Exception {
Seller seller = new Seller("hssm', "이동욱", "scala@hssm.kr");
repository.remove(seller);
Seller actualSeller = repository.findById("hssm");
assertEquals("존재하지 않는 ID입니다", actualSeller.getId());
}
7.3.1.3 SQL 스크립트가 테스트 수행 시에 실행되도록 만든다.
-
SQL 스크립트를 테스트 수행 전에 실행시키는 방법은 매우 다양하다.
- 테스트 실행 상태를 만드는 작업과 테스트 수행 작업을 분리해서 관리할 수 있다.
- SQL 문장만 제대로 작성하면 되기 때문에 편리
- SQL 스크립트를 실행할 유틸리니타 ANT등이 필요
- 자체 SQL 파일이 많아지면 별도 관리가 필요
7.3.1.3.1 ANT SQL TASK 이용
7.3.1.3.2 SQL 스크립트 파일을 실행(자바의 커맨드 실행 방법으로)
7.3.1.3.3 Spring 프레임워크의 SimpleJdbcTestUtils를 이용
7.3.1.4 DbUnit을 사용한다.
- 앞에서 많이 봄
7.3.2 테스트 전후의 데이터베이스 상태를 비교하는 방법
7.3.2.1 예상 결과를 미리 다른 테이블에 넣고 대상 테이블과 예상 테이블 각각 SELECT로 비교
- 매우 불편
7.3.2.2 예상 결과를 미리 문자열로 만들고 Select해서 비교
- 대형 DBMS는 실행 결과를 파일로 만들어주는 기능을 제공
7.3.2.3 DbUnit과 Unitils를 함께 사용
- 이미 앞에서 많이 봄
@Test
@ExpectedDataSet("expected_seller.xml")
public void testAddNewSeller() throws Exception {
Seller newSeller = new Seller("hssm","이동욱","scala@hssm.kr");
Repository repository = new DatabaseRepository();
repository.add(newSeller);
}
7.4 안티패턴((anti-pattern), 전통적으로 잘못 인식되어 있는 테스트 메소드의 리팩토링
- 기본적인좋은 테스트 케이스는 다음과 같은 규칙을 지닌다
- 하나의 테스트 케이스는 외부와 독립적. 따라서 다른 케이스에 영향을 주거나 받지 않아야 한다.
-
하나의 일관된 시나리오를 갖고 있어야 한다.
- 그러나 아래의 경우 어떤지 생각해보자.
public class AccountTest {
private Account account;
@Before
public void setUp(){
account = new Account(10000);
}
@Test
public void testDeposit() throws Exception {
account.deposit(1000);
assertEquals(11000, account.getBalance());
}
@Test
public void testWithdraw() throws Exception {
account.withdraw(1000);
assertEquals(9000, account.getBalance());
}
}
- 이때 testWithdraw의 경우 독립적이지가 않다.
- 그래서 아래 같이 바꾸어 보았다.
public class AccountTest {
@Before
public void setUp(){
}
@Test
public void testDeposit() throws Exception {
Account account = new Account(10000);
account.deposit(1000);
assertEquals(11000, account.getBalance());
}
@Test
public void testWithdraw() throws Exception {
Account account = new Account(10000);
account.withdraw(1000);
assertEquals(9000, account.getBalance());
}
}
- 이런 경우 Account 생성이 중복이다.
- 비록 중복이 되었지만 시나리오 일부라면 고민해봐야한다.
- 가독성 측면에서, TDD 규칙으로 볼때는 이 것이 더 완결성을 가진다.
- setUp, tearDown 같은 테스트 픽스처 메소드의 경우는 테스트 시나리오에 참가하는 객체들에게 필요한 사전 조건이나 기반 환경을 제공할 때 사용하면 더 좋음.