애플리케이션은 계속 변하고 복잡해져 간다. 그 변화에 대응하는 첫 번째 전략이 화장과 변화를 고려한 객체지향적 설계와 그것을 효과적으로 담아낼 수 있는 IoC/DI 같은 기술이라면, 두 번째 전략은 만들어진 코드를 확신할 수 있게 해주고, 변화에 유연하게 대처할 수 있는 자신감을 주는 테스트 기술이다.
테스트는 스프링을 학습하는 데 있어 가장 효과적인 방법 중 하나다. 테스트의 작성은 스프링의 다양한 기술을 활용하는 방법을 이해하고 검증하고, 실전에 적용하는 방법을 익히는 데 효과적으로 사용될 수 있다.
이전에 만들었던 테스트 코드는 main()
메서드를 이용해 UserDao
오브젝트의 add()
, get()
메서드를 호출하고, 그 결과를 화면에 출력해서 그 값을 눈으로 확인시켜준다. 이렇게 만든 테스트용 main()
메서드를 반복적으로 실행해가면서 처음 설계한 대로 기능이 동작하는지를 매 단계 확인한 덕분에, 다양한 방법으로 초난감 UserDao
코드의 설계와 코드를 개선했고, 심지어 스프링을 적용해서 동작하게 만들 수도 있었다.
테스트란 결국 내가 예상하고 의도했던 대로 코드가 정확히 동작하는지를 확인해서, 만든 코드를 확신할 수 있게 해주는 작업이다. 또한 테스트의 결과가 원하는 대로 나오지 않는 경우에는 코드나 설계에 결함이 있음을 알 수 있다. 이를 통해 코드의 결함을 제거해가는 작업, 일명 디버깅을 거치게 되고, 결국 최종적으로 테스트가 성공하면 모든 결함이 제거됐다는 확신을 얻을 수 있다.
public class UserDaoTest {
public static void main(String[] args) throws SQLException {
ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
User user = new User();
user.setId("user");
user.setName("백기선");
user.setPassword("married");
dao.add(user);
System.out.println(user.getId() + " 등록 성공");
User user2 = dao.get(user.getId());
System.out.println(user2.getName());
System.out.println(user2.getPassword());
System.out.println(user.getId() + " 조회 성공");
}
}
이 테스트 코드의 내용을 정리해보면 다음과 같다.
main()
메서드를 이용한다.UserDao
의 오브젝트를 가져와 메서드를 호출한다.User
오브젝트)을 직접 코드에서 만들어 넣어준다.이 테스트 방법에서 가장 돋보이는 점
main()
메서드를 이용해 쉽게 테스트 수행을 가능하게 했다는 점UserDao
를 직접 호출해서 사용한다는 점보통 웹 프로그램에서 사용하는 DAO를 테스트하는 방법은 다음과 같다. DAO 만든 뒤 바로 테스트하지 않고, 서비스 계층, MVC 프레젠테이션 계층까지 포함한 모든 입출력 기능을 대충이라도 코드로 다 만든다. 이렇게 만들어진 테스트용 웹 애플리케이션을 서버에 배치한 뒤, 웹 화면을 띄워 폼을 열고, 값을 입력한 뒤 버튼을 눌러 등록해본다. 에러가 없으면, 이번엔 검색 폼이나 파라미터를 지정할 수 있는 URL을 사용해서 방금 입력한 데이터를 다시 가져올 수 있는지 테스트해본다.
이렇게 웹 화면을 통해 값을 입력하고, 기능을 수행하고, 결과를 확인하는 방법은 가장 흔히 쓰이는 방법이지만, DAO뿐만 아니라 서비스 클래스, 컨트롤러, JSP 뷰 등 모든 레이어의 기능을 다 만들고 나서야 테스트가 가능하다는 점이 가장 큰 문제다. 테스트를 하는 중에 에러가 나거나 테스트가 실패했다면, 과연 어디에서 문제가 발생했는지를 찾아내야 하는 수고도 필요하다. 하나의 테스트를 수행하는 데 참여하는 클래스와 코드가 너무 많기 때문이다.
테스트하고자 하는 대상이 명확하다면 그 대상에만 집중해서 테스트하는 것이 바람직하다. 따라서 테스트는 가능하면 작은 단위로 쪼개서 집중해서 할 수 있어야 한다. 관심사의 분리 원리에 의해, 테스트의 관심이 다르다면 테스트할 대상을 분리하고 집중해서 접근해야 한다.
UserDaoTest
는 한 가지 관심에 집중할 수 있게 작은 단위로 만들어진 테스트다. UserDaoTest
의 테스트를 수행할 땐 간단히 IDE나 도스창에서도 테스트 수행이 가능하다. 에러가 나거나 원치 않는 결과가 나온다면, 그것은 UserDao
코드나 아니면 DB 연결 방법 정도에서 문제가 있는 것이니 원인을 빠르게 찾아낼 수 있다.
이렇게 작은 단위의 코드에 대해 테스트를 수행한 것을 단위 테스트(unit test)라고 한다.
여기서 말하는 단위는 그 크기와 범위가 딱 정해진 건 아니다. 크게는 사용자 관리 기능을 모두 통틀어서 하나의 단위로 볼 수도 있다. 충분히 하나의 관심에 집중해서 효율적으로 테스트할 만한 범위의 단위라고 보면 된다.
일반적으로 단위는 작을수록 좋다. 단위를 넘어서는 다른 코드들은 신경 쓰지 않고, 참여하지도 않고 테스트가 동작할 수 있으면 좋다. UserDao
는 서비스, MVC 계층이 참여하고 웹 화면과 서버까지 동원하지 않고도 테스트가 가능했다. DAO라는 기능과 DB까지로 단위를 잡고 집중해서 테스트할 수 있었다. 그래서 UserDaoTest를 단위 테스트라고 부를 수 있다. 또한, 지금까지 UserDaoTest를 수행할 때 매번 USER 테이블의 내용을 비우고 테스트를 진행했다. 이렇게 사용할 DB의 상태를 테스트가 관장하고 있다면 이는 단위 테스트라고 해도 된다. 다만, DB의 상태가 매번 달라지고, 테스트를 위해 DB를 특정 상태로 만들어줄 수 없다면 그때는 단위 테스트로서 가치가 없어진다. 그런 차원에서 통제할 수 없는 외부의 리소스에 의존하는 테스트는 단위 테스트가 아니라고 보기도 하는 것이다.
📌 때로는 웹 사용자 인터페이스부터 시작해 DB에 이르기까지의 애플리케이션 전 계층이 참여하고, 또 단순 사용자 등록 작업 하나가 아닌, 초기 등록에서부터 등록 성공하면 로그인하고, 각종 기능을 모두 사용한 다음 로그아웃까지 하는 전 과정을 하나로 묶어서 테스트할 필요가 있다. 각 단위 기능은 잘 동작하는데 묶어놓으면 안되는 경우가 종종 발생하기 때문이다. 따라서 이런 길고 많은 단위가 참여하는 테스트도 언젠가는 필요하다.
때로는 단위 테스트 없이 바로 이런 긴 테스트만 하는 경우도 있는데, 아마도 수많은 에러를 만나거나 에러는 안 나지만 제대로 기능이 동작하지 않는 경험을 하게 될 것이다. 각 단위별로 테스트를 먼저 모두 진행하고 나서 이런 긴 테스트를 시작한다면, 그대로 역시 예외가 발생하거나 테스트가 실패할 수는 있겠지만, 이미 각 단위별로 충분한 검증을 마치고 오류를 잡았으므로 훨씬 나을 것이다.
이렇게 작은 단위로 나눠서 테스트하는 단위 테스트를 하는 이유는 개발자가 설계하고 만든 코드가 원래 의도한 대로 동작하는지를 개발자 스스로 빨리 확인받기 위해서다. 이때 확인의 대상과 조건이 간단하고 명확할수록 좋다. 그래서 작은 단위로 제한해서 테스트하는 것이 편리하다. 단위 테스트는 주로 개발자가 만든 코드를 스스로 확인하기 위해 사용하는데, 그래서 이를 개발자 테스트라고도 한다.
정리하자면, UserDaoTest
는 UserDao
라는 작은 단위의 데이터 액세스 기능만을 테스트하기 위해 만들어졌고, 그 외의 계층이 참여하지 않기 때문에 이는 분명 단위 테스트다. 또한 우리가 만들고 개선한 코드가 처음 설계하고 의도한 대로 바르게 동작했는지를 확인하기 위해 개발자 입장에서 만든 것이므로 이를 개발자 테스트라고 부를 수도 있다.
UserDaoTest
의 한 가지 특징은 테스트할 데이터가 코드를 통해 제공되고, 테스트 작업 역시 코드를 통해 자동으로 실행한다는 점이다. UserDaoTest
는 자바 클래스의 main() 메서드를 실행하는 가장 간단한 방법만으로 테스트의 전 과정이 자동으로 진행된다. 번거롭게 매번 입력할 필요도 없고, 테스트를 시작하기 위해 서버를 띄우고, 브라우저를 열어야 하는 불편함도 없다. 그러니 테스트를 자주 수행해도 부담이 없다.
이렇게 테스트는 자동으로 수행되도록 코드로 만들어지는 것이 중요하다. 하지만 모든 클래스에 자신을 테스트하는 main()
메서드를 갖고 있는 즉, 애플리케이션을 구성하는 클래스 안에 테스트 코드를 포함시키는 것보단 별도로 테스트용 클래스를 만들어서 테스트 코드를 넣는 편이 낫다.
자동으로 수행되는 테스트의 장점은 자주 반복할 수 있다는 것이다. 번거로운 작업이 없고 테스트를 빠르게 실행할 수 있기 때문에 언제든 코드를 수정하고 나서 테스트를 해볼 수 있다. 때로는 단 한 줄의 코드를 건드렸는데 전체 기능에 영향을 주기도 하기 때문에 그럴 때 만들어둔 기능에 대한 테스트가 있다면 수정 후 빠르게 전체 테스트를 수행해서 수정 때문에 다른 기능에 문제가 발생하지는 않는지 재빨리 확인하고, 성공한다면 마음에 확신을 얻을 수 있다.
처음 만든 초난감 DAO 코드를, 스프링을 이용한 깔끔하고 완성도 높은 객체지향적 코드로 발전시키는 과정의 일등 공신은 바로 이 테스트였다. 중간에 잘못 설계했거나, 중요한 코드를 한 줄 빼먹었거나, 수정에 실수가 있었다면 테스트를 통해 무엇이 잘못됐는지 바로 확인할 수 있었을 것이다.
만약 처음부터 스프링을 적용하고 XML로 설정을 만들고 모든 코드를 다 만들고 난 뒤에 이를 검증하려고 했다면, 아마 쏟아지는 에러 메시지에 기가 질려서 무엇을 해야 할지 몰라 막막해졌을지도 모른다. 하지만 일단 단순 무식한 방법으로 정상동작하는 코드를 만들고, 테스트를 만들어뒀기 때문에 매우 작은 단계를 거쳐가면서 계속 코드를 개선해나갈 수 있었다.
또 테스트를 이용하면 새로운 기능도 기대한 대로 동작하는지 확인할 수 있을 뿐 아니라, 기존에 만들어뒀던 기능들이 새로운 기능을 추가하느라 수정한 코드에 영향을 받지 않고 여전히 잘 동작하는지를 확인할 수도 있다.
UserDaoTest가 UI까지 동원되는 번거로운 수동 테스트에 비해 장점이 많은건 사실이나, 단점도 있다.
UserDaoTest
는 테스트를 수행하는 과정과 입력 데이터의 준비를 모두 자동으로 진행하도록 만들어졌다. 하지만 여전히 사람의 눈으로 확인하는 과정이 필요하다. add()
에서 User 정보를 DB에 등록하고, 이를 다시 get()
을 이용해 가져왔을 때 입력한 값과 가져온 값이 일치하는지를 테스트 코드는 확인해주지 않는다. 단지 콘솔에 값만 출력해줄 뿐이다. 결국 그 콘솔에 나온 값을 보고 등록과 조회가 성공적으로 되고 있는지를 확인하는 건 사람의 책임이다. 테스트 수행은 코드에 의해 자동으로 진행되긴 하지만 테스트의 결과를 확인하는 일은 사람의 책임이므로 완전히 자동으로 테스트되는 방법이라고 말할 수가 없다. 검증해야 할 양이 많고 복잡해지면 역시 불편함을 느낄 수밖에 없고, 작은 차이는 미처 발견하지 못하고 넘어가는 실수를 할 가능성도 있다. main()
메서드라고 하더라도 매번 그것을 실행하는 것은 제법 번거롭다. 만약 DAO가 수백 개가 되고 그에 대한 main()
메서드도 그만큼 만들어진다면, 전체 기능을 테스트해보기 위해 main()
메서드를 수백 번 실행하는 수고가 필요하다. 또한 그 결과를 눈으로 확인해서 기록하고, 이를 종합해서 전체 기능을 모두 테스트한 결과를 정리하려면 이것도 제법 큰 작업이 된다. 그래서 main()
메서드를 이용하는 방법보다 좀 더 편리하고 체계적으로 테스트를 실행하고 그 결과를 확인하는 방법이 필요하다. USERDAOTEST
개선UserDaoTest
의 두 가지 문제점을 개선해보자.
첫 번째 문제점인 테스트 결과의 검증 부분을 코드로 만들어보자. 이 테스트를 통해 확인하고 싶은 사항은, add()
에 전달한 User 오브젝트에 담긴 사용자 정보와 get()
을 통해 다시 DB에서 가져온 User
오브젝트의 정보가 서로 정확히 일치하는가이다. 정확히 일치한다면 모든 정보가 빠짐없이 DB에 등록됐고, 이를 다시 DB에서 정확히 가져왔다는 사실을 알 수 있다.
add()
에 전달한 User
오브젝트와 get()
을 통해 가져오는 User
오브젝트의 값을 비교해서 일치하는지 확인한다.name
과 passoword
중 어떤 것 때문에 실패했는지 알 수 있도록 뒤에 간단히 필드 이름을 추가해서 표시System.out.println(user2.getName());
System.out.println(user2.getPassword());
System.out.println(user.getId() + " 조회 성공");
if (!user.getName().equals(user2.getName())) {
System.out.println("테스트 실패 (name)");
}
else if (!user.getPassword().equals(user2.getPassword())) {
System.out.println("테스트 실패 (password)");
}
else {
System.out.println("조회 테스트 성공");
}
add()
를 통한 등록 자체는 별다르게 검증할 것이 없다. add()
를 호출하고 나서 에러가 발생하지 않으면 일단 성공으로 간주한다. 만약 add()
메서드의 잘못으로 등록이 안 된 게 있다면 뒤의 get()
을 통한 검증 코드를 통과하지 못할 것이다.
이렇게 해서 테스트의 수행과 테스트 값 적용, 그리고 결과를 검증하는 것까지 거의 모든 과정을 자동화한 테스트가 만들어졌다. 혹시 예외가 발생하거나 실패 메시지가 나오면 그 원인을 찾아서 코드를 수정하면 되고, 성공 메시지가 나오면 기능이 문제없이 완성됐다는 확신을 갖고 다음 작업으로 넘어가면 된다.
이 테스트는 UserDao
의 두 가지 기능이 정상적으로 동작하는지를 언제든지 손쉽게 확인할 수 있게 해준다. 따라서 이 코드의 동작에 영향을 미칠 수 있는 어떤 변화라도 생기면 언제든 다시 실행해볼 수 있다. 이렇게 개발 과정애서, 또는 유지보수를 하면서, 새로 도입한 기술의 적용에 문제가 없는지 확인할 수 있는 가장 좋은 방법은 빠르게 실행 가능하고 스스로 테스트 수행과 기대하는 결과에 대한 확인까지 해주는 코드로 된 자동화된 테스트를 만들어두는 것이다.
main()
메서드를 이용한 테스트 작성 방법만으로는 애플리케이션 규모가 커지고 테스트 개수가 많아지면 테스트를 수행하는 일이 점점 부담이 될 것이다. 이미 자바에서는 프로그래머를 위한 자바 테스팅 프레임워크라고 불리는 JUnit라는 테스트 지원 도구가 있다.
지금까지 만들었던 main()
메서드 테스트를 JUnit을 이용해 다시 작성해보겠다. JUnit은 프레임워크다. 프레임워크의 기본 동작원리가 바로 제어의 역전(IoC)이다. 프레임워크는 개발자가 만든 클래스에 대한 제어 권한을 넘겨받아서 주도적으로 애플리케이션의 흐름을 제어한다. 따라서 프레임워크에서 동작하는 코드는 main()
메서드도 필요 없고 오브젝트를 만들어서 실행시키는 코드를 만들 필요도 없다.
테스트가 main()
메서드로 만들어졌다는 건 제어권을 직접 갖는다는 의미이기 때문에 그런 면에서 프레임워크에 적용하기엔 적합하지 않다. 그래서 가장 먼저 할 일은 main()
메서드에 있던 테스트 코드를 일반 메서드로 옮기는 것이다.
JUnit 프레임워크가 요구하는 테스트 메서드 조건
@Test
를 붙여줘야 한다. void
형이어야 한다.📌 책은 JUnit4 기준이지만, JUnit5에선 public 접근자는 생략해도 된다. private만 아니면 된다.
참고 : https://junit.org/junit5/docs/current/user-guide/
class UserDaoTest {
@Test
void addAndGet() throws SQLException {
ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
...
}
}
main()
대신 일반 메서드로 만든다. 테스트의 의도가 무엇인지 알 수 있는 이름이 좋다.테스트의 결과를 검증하는 if/else
문장을 JUnit이 제공하는 방법을 이용해 전환해보자.
if
문장의 기능을 asserThat
이라는 스태틱 메서드를 이용해 변경할 수 있다.assertThat()
메서드의 첫 번째 파라미터의 값을 뒤에 나오는 매처(matcher)라고 불리는 조건으로 비교해서 일치하면 다음으로 넘어가고, 아니면 테스트가 실패하도록 만들어준다. is()
는 매처의 일종으로 equals()
로 비교해주는 기능을 가지고 있다. assertThat()
에서 실패하지 않고 테스트 메서드의 실행이 완료되면 테스트가 성공했다고 인식한다. 📌 JUnit5에서 테스트하는 것이기 때문에 원본과 import가 다르다.
참고 : https://aonee.tistory.com/2
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.equalTo;
class UserDaoTest {
@Test
void addAndGet() throws SQLException {
ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
User user = new User();
user.setId("gyumee");
user.setName("박성철");
user.setPassword("springno1");
dao.add(user);
User user2 = dao.get(user.getId());
assertThat(user2.getName(), is(equalTo(user.getName())));
}
}
스프링 컨테이너와 마찬가지로 JUnit 프레임워크도 자바 코드로 만들어진 프로그램이므로 어디선가 한 번은 JUnit 프레임워크를 시작시켜 줘야 한다.
어디에든 main()
메서드를 하나 추가하고, 그 안에 JUnitCore
클래스의 main
메서드를 호출해주는 코드를 넣어주면 된다. 메서드 파라미터에는 @Test
테스트 메서드를 가진 클래스의 이름을 넣어준다.
📌 org.junit.runner.JUnitCore은 JUnit5에서 사용할 수 없기 때문에 test 폴더에서 실행해준다.
테스트 클래스를 실행하면 다음과 같은 메시지가 출력된다.
Process finished with exit code 0
만약 코드에 이상이 있어서 assertThat()
의 검증에서 실해하면 다음과 같은 메시지가 나온다.
java.lang.AssertionError:
Expected: is "백기선"
but: was null
Expected :is "백기선"
Actual :null
...
at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:54)
Process finished with exit code 255
null
이 나왔다는 것임을 알 수 있다. assertThat()
을 이용해 검증을 했을 때 기대한 결과가 아니면 이 AssertionError
를 던진다.assert()
의 조건을 만족하지 못하거나 테스트 수행 중에 일반 예외가 발생한 경우에 테스트 수행은 중단되고 테스트는 실패한다.JUnit은 사실상 자바의 표준 테스팅 프레임워크라고 불릴 만큼 폭넓게 사용되고 있다. 스프링을 학습하고 제대로 활용하려면 최소한의 JUnit 테스트 작성 방법과 실행 방법은 알고 있어야 한다.
가장 좋은 JUnit 테스트 실행 방법은 자바 IDE에 내장된 JUnit 테스트 지원 도구를 사용하는 것이다.
📌 책에선 Eclipse에 대해 설명하지만, 여기선 Intellij 기준으로 설명할 것이다.
Intellij는 JUnit 테스트를 지원하는 기능을 제공하고 있다. @Test
가 들어 있는 테스트 클래스를 선택한 뒤 실행하면 된다.
테스트가 시작되면 하단의 Run 탭에서 JUnit 테스트 진행 상황을 보여준다.
이 뷰에서 확인할 수 있는 것
@Test
가 붙은 테스트 메서드의 이름 테스트가 실패하면 코드를 수정한 뒤 다시 실행하면 된다. 또, 테스트 목록에서 테스트 클래스나 테스트 메서드를 더블클릭하면 해당 코드를 편집기에 보여준다.
테스트 실패했을 때 나타나는 결과다. 이번엔 한 개의 테스트 메서드가 실패했음을 알 수 있다. 오른쪽 창에 실패한 이유와 함께 테스트 코드에서 검증에 실패한 assertThat()
의 위치도 나와 있다.
JUnit은 한 번에 여러 테스트 클래스를 동시에 실행할 수도 있다. Intellij에서는 소스 트리에서 특정 패키지를 선택하고 Run 'Tests in ...'
를 선택하면, 해당 패키지 아래에 있는 모든 JUnit 테스트를 한 번에 실행해준다. 소스 폴더나 프로젝트 전체를 선택해서 모든 테스트를 한 번에 실행할 수도 있다. Intellij에서 ⌃
+⇧
+ R
를 순서대로 누르면 선택해둔 클래스나 패키지, 프로젝트의 테스트가 바로 실행된다.
이렇게 JUnit 테스트의 실행과 그 결과를 확인하는 방법이 매우 간단하고 직관적이며 소스와 긴밀하게 연동돼서 결과를 볼 수 있기 때문에, 개발 중에 테스트를 실행하고자 할 때는 Intellij 같은 IDE의 지원을 받는 것이 가장 편리하다.
프로젝트의 빌드를 위해 ANT나 메이븐(Maven) 같은 빌드 툴과 스크립트를 사용하고 있다면, 빌드 툴에서 제공하는 JUnit 플러그인이나 태스크를 이용해 JUnit 테스트를 실행할 수 있다. 테스트 실행 결과는 옵션에 따라서 HTML이나 텍스트 파일의 형태로 보기 좋게 만들어진다.
개발자 개인별로는 IDE에서 JUnit 도구를 활용해 테스트를 실행하는 게 가장 편리하다. 그런데 여러 개발자가 만든 코드를 모두 통합해서 테스트를 수행해야 할 때도 있다. 이런 경우에는 서버에서 모든 코드를 가져와 통합하고 빌드한 뒤에 테스트를 수행하는 것이 좋다. 이때는 빌드 스크립트를 이용해 JUnit 테스트를 실행하고 그 결과를 메일 등으로 통보받는 방법을 사용하면 된다.
지금까지 테스트를 실행하면서 가장 불편했던 일은, 매번 UserDaoTest
테스트를 실행하기 전에 DB의 USER 테이블 데이터를 모두 삭제해줘야 하는 것이였다.
여기서의 문제는 테스트가 외부 상태에 따라 성공하기도 하고 실패하기도 한다는 점이다. DB 서버가 다운됐다거나 네트워크에 장애가 생겨서 DB에 접근하지 못하는 예외적인 상황이 아니고서야 말이다. 반복적으로 테스트를 했을 때 테스트가 실패하기도 하고 성공하기도 한다면 이는 좋은 테스트라고 할 수가 없다. 코드에 변경사항이 없다면 테스트는 항상 동일한 결과를 내야 한다.
UserDaoTest
의 문제는 이전 테스트 때문에 DB에 등록된 중복 데이터가 있을 수 있다는 점이다. 가장 좋은 해결책은 addAndGet()
테스트를 마치고 나면 테스트가 등록한 사용자 정보를 삭제해서, 테스트를 수행하기 이전 상태로 만들어주는 것이다. 그러면 테스트를 아무리 여러 번 반복해서 실행하더라도 항상 동일한 결과를 얻을 수 있다.
deleteAll()
의 getCount()
추가일관성 있는 결과를 보장하는 테스트를 만들기 위해 준비할 게 있는데, 바로 UserDao
에 새로운 기능을 추가하는 것이다.
deleteAll()
USER 테이블의 모든 레코드를 삭제해 주는 기능이 있다.
public void deleteAll() throws SQLException{
Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("delete from users");
ps.executeUpdate();
ps.close();
c.close();
}
getCount()
USER 테이블의 레코드 개수를 돌려준다.
public int getCount() throws SQLException {
Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("select count(*) from users");
ResultSet rs = ps.executeQuery();
rs.next();
int count = rs.getInt(1);
rs.close();
ps.close();
c.close();
return count;
}
deleteAll()
과 getCount()
의 테스트deleteAll()
과 getCount()
메서드 기능은 add()
와 get()
처럼 독립적으로 자동 실행되는 테스트를 만들기가 좀 애매하다. 굳이 테스트를 하자면 USER 테이블에 수동으로 데이터를 넣고 deleteAll()
을 실행해야하는데, 사람이 테스트 과정에 참여해야 하니 자동화돼서 반복적으로 실행 가능한 테스트 방법은 아니다. 그래서, 새로운 테스트를 만들기보다는 addAndGet()
테스트를 확장하여 사용하자.
addAndGet()
테스트가 시작될 때 deleteAll()
을 이용해 테이블의 모든 내용을 삭제해주는 것이 어떨까? 만약 deleteAll()
이 잘 동작한다면 매번 USER 테이블을 수동으로 삭제하지 않아도 된다.
하지만 deleteAll()
자체도 아직 검증이 안됐기 때문에 무작정 다른 테스트에 적용할 수는 없다. 그래서 getCount()
를 함께 적용해보자. deleteAll()
을 실행한 직후에 getCount()
의 결과 값을 검증하고 코드를 넣어보자. 하지만 getCount()
조차도 검증이 되지 않았다. 검증 안 된 두개를 붙였는데 우연히 테스트가 통과한다고 안심하는 것은 바람직하지 못하다.
그래서 getCount()
에 대한 검증 작업을 하나 더 추가한다. 이미 앞에서 add()
메서드는 검증됐으니 add()
메서드를 실행한 뒤에 getCount()
의 결과를 한 번 더 확인해보자. deleteAll()
직후에는 0이 나오고 add()
직후에는 1이 나온다면, getCount()
의 기능이 바르게 동작한다고 볼 수 있다. 그리고 getCount()
가 바르게 동작한다는 확신이 있다면, deleteAll()
직후에 항상 0이 나오는 것을 보고 deleteAll()
의 기능도 바르게 동작한다고 생각할 수 있다.
deleteAll()
과 getCount()
가 추가된 addAndGet()
테스트@Test
void addAndGet() throws SQLException {
...
dao.deleteAll();
assertThat(dao.getCount(), is(0));
User user = new User();
user.setId("user");
user.setName("백기선");
user.setPassword("married");
dao.add(user);
assertThat(dao.getCount(), is(1));
User user2 = dao.get(user.getId());
assertThat(null, is(equalTo(user.getName())));
assertThat(user2.getPassword(), is(equalTo(user.getPassword())));
}
이제 테스트를 실행해보자. 이전에는 테스트를 하기 전에 매번 직접 DB에서 데이터를 삭제해야 했지만, 이제는 그런 번거로운 과정이 필요 없어졌다. 테스트가 어떤 상황에서 반복적으로 실행된다고 하더라도 동일한 결과가 나올 수 있게 된 것이다. 단위 테스트는 코드가 바뀌지 않는다면 매번 실행할 때마다 동일한 테스트 결과를 얻을 수 있어야 한다.
동일한 테스트 결과를 얻을 수 있는 다른 방법도 있다. addAndGet() 테스트를 마치기 직전에 테스트가 변경하거나 추가한 데이터를 모두 원래 상태로 만들어주는 것이다. 물론 이것도 나쁜 방법은 아니다. 하지만 addAndGet() 테스트만 DB를 사용할 것이 아니라면, addAndGet() 테스트 실행 이전에 다른 이유로 USER 테이블에 데이터가 들어가있다면 이때는 테스트가 실패할 수도 있다.
스프링은 DB를 사용하는 코드를 테스트하는 경우 매우 편리한 테스트 방법을 제공해준다. 하지만 아직 스프링의 기능을 충분히 살펴보지 않았으므로 당분간은 이 방법을 사용하자.
❗️ 단위 테스트는 항상 일관성 있는 결과가 보장돼야 한다는 점을 잊어선 안 된다. DB에 남아 있는 데이터와 같은 외부 환경에 영향을 받지 말아야 하는 것은 물론이고, 테스트를 실행하는 순서를 바꿔도 동일한 결과가 보장되도록 만들어야 한다.
두 개 이상의 레코드를 add()
했을 때는 getCount()
의 실행 결과가 어떻게 될까? 뭐, deleteAll()
실행했을 때와 add()
를 한 번 호출한 뒤, 이렇게 두 가지를 해봤으니 나머지도 당연히 잘될 것이라 추정할 수도 있다. 하지만 미처 생각지도 못한 문제가 숨어 있을지도 모른다. 테스트를 안 만드는 것도 위험한 일이지만, 성의 없이 테스트를 만드는 바람에 문제가 있는 코드인데도 테스트가 성공하게 만드는 건 더 위험하다.
getCount()
테스트이번에는 여러 개의 User
를 등록해가면서 getCount()
의 결과를 매번 확인해보겠다. 이 테스트 기능을 기존의 addAndGet()
메서드에 추가하는 건 별로 좋은 생각이 아니다. 테스트 메서드는 한 번에 한 가지 검증 목적에만 충실한 것이 좋다. JUnit은 하나의 클래스 안에 여러 개의 테스트 메서드가 들어가는 것을 허용한다.
User
테이블의 데이터를 모두 지우고 getCount()
로 레코드 개수가 0임을 확인한다.getCount()
의 결과가 하나씩 증가하는지 확인한다.테스트를 만들기 전에 먼저 User
클래스에 한 번에 모든 정보를 넣을 수 있도록 초기화가 가능한 생성자를 추가한다.
public User(String id, String name, String password) {
this.id = id;
this.name = name;
this.password = password;
}
public User() {
}
❗️자바빈의 규약을 따르는 클래스에 생성자를 명시적으로 추가했을 때는 파라미터가 없는 디폴트 생성자도 함께 정의해주는 것을 잊지 말자
새로 만든 생성자를 이용하면 addAndGet()
테스트의 코드도 다음과 같이 간략히 수정할 수 있다.
User user = new User("user", "백기선", "married");
...
이렇게 테스트 코드를 수정한 뒤에도 잊지 말고 테스트를 다시 실행해본다. 모든 코드의 수정 후에는 그 수정에 영향을 받을 만한 테스트를 실행하는 것을 잊지 말자.
@Test
void count() throws SQLException {
ApplicationContext context = new GenericXmlApplicationContext(
"applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
User user1 = new User("gyumee", "박성철", "springno1");
User user2 = new User("leegw700", "이길원", "springno2");
User user3 = new User("bumjin", "박범진", "springno3");
dao.deleteAll();
assertThat(dao.getCount(), is(0));
dao.add(user1);
assertThat(dao.getCount(), is(1));
dao.add(user2);
assertThat(dao.getCount(), is(2));
dao.add(user3);
assertThat(dao.getCount(), is(3));
}
이제 테스트를 실행한 뒤 테스트 결과를 살펴보면 두 개의 테스트가 모두 실행됐고 실패한 테스트는 하나도 없다고 나올 것이다.
주의해야 할 점은 JUnit은 특정한 테스트 메서드의 실행 순서를 보장해주지 않는다. 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것이다. 예를 들어 addAndGet()
메서드에서 등록한 사용자 정보를 count()
테스트에서 활용하는 식으로 테스트를 만들면 안 된다. 모든 테스트는 실행 순서에 상관없이 독립적으로 항상 동일한 결과를 낼 수 있도록 해야 한다.
addAndGet()
테스트 보완add()
기능은 충분히 검증된 것 같다. 하지만 id
를 조건으로 해서 사용자를 검색하는 기능을 가진 get()
에 대한 테스트는 조금 부족한 감이 있다. get()
이 파라미터로 주어진 id에 해당하는 사용자를 가져온 것인지 아닌지 테스트에서 검증하지는 못했다.
그래서 get()
메서드에 대한 테스트 기능을 좀 더 보완할 필요가 있다. User
를 하나 더 추가해서 두 개의 User
를 add()
하고, 각 User
의 id
를 파라미터로 전달해서 get()
을 실행하도록 만들어보자. 이렇게 하면 주어진 id
에 해당하는 정확한 User
정보를 확인하는지 확인할 수 있다.
User
오브젝트를 선언한다.User
의 id
로 get()
을 실행하면 첫 번째 User
의 값을 가진 오브젝트를 돌려주는 지 확인한다.User
에 대해서도 같은 방법으로 검증한다. get()
테스트 기능을 보완한 addAndGet()
테스트@Test
void addAndGet() throws SQLException {
...
UserDao dao = context.getBean("userDao", UserDao.class);
User user1 = new User("gyumee", "박성철", "springno1");
User user2 = new User("leegw700", "이길원", "springno2");
dao.deleteAll();
assertThat(dao.getCount(), is(0));
dao.add(user1);
dao.add(user2);
assertThat(dao.getCount(), is(2));
User userget1 = dao.get(user1.getId());
assertThat(userget1.getName(), is(equalTo(user1.getName())));
assertThat(userget1.getPassword(), is(equalTo(user1.getPassword())));
User userget2 = dao.get(user1.getId());
assertThat(userget2.getName(), is(equalTo(user2.getName())));
assertThat(userget2.getPassword(), is(equalTo(user2.getPassword())));
}
get()
예외조건에 대한 테스트get()
메서드에 전달된 id
값에 해당하는 사용자 정보가 없다면 어떤 결과가 나오면 좋을까? 이럴땐 두 가지 방법이 있을 것이다. 하나는 null
과 같은 특별한 값을 리턴하는 것이고, 다른 하나는 id
에 해당하는 정보를 찾을 수 없다고 예외를 던지는 것이다. 여기서는 후자의 방법을 써보자.
주어진 id
에 해당하는 정보가 없다는 의미를 가진 예외 클래스가 하나 필요하다. 예외를 하나 정의할 수도 있지만, 스프링이 미리 정의해놓은 예외인 EmptyResultDataAccessException
을 가져다 쓰도록 하겠다.
UserDao
의 get()
메서드에서 쿼리를 실행해 결과를 가져왔을 때 아무것도 없으면 이 예외를 던지도록 만들면 된다. 하지만 일반적으로 테스트 중에 예외가 던져지면 테스트 메서드의 실행은 중단되고 테스트는 실패한다. assertThat()
을 통한 검증 실패가 아닌 테스트 에러라고 볼 수 있다. 이번에는 반대로 테스트 진행 중에 특정 예외가 던져지면 테스트가 성공한 것이고, 예외가 던져지지 않고 정상적으로 작업을 마치면 테스트가 실패했다고 판단해야 한다.
이런 경우를 위해 JUnit은 예외조건 테스트를 위한 특별한 방법을 제공해준다. 일단 테스트 메서드를 하나 더 추가한다.
User
데이터를 지우고 존재하지 않는 id
로 get()
메서드를 호출한다.EmptyResultDataAccessException
이 던져지면 성공이고, 아니면 실패다. 📌 책은 JUnit4에서의
@Test
애노테이션의expected
앨리먼트를 사용했다. 하지만 여기선 JUnit5의assertThrows
를 사용했다.
get()
메서드의 예외상황에 대한 테스트@Test
void getUserFailure() throws SQLException{
ApplicationContext context = new GenericXmlApplicationContext(
"applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
dao.deleteAll();
assertThat(dao.getCount(), is(0));
Assertions.assertThrows(EmptyResultDataAccessException.class, () -> {
dao.get("unknown_id");
});
}
👉 JUnit5의 assertThrows
assertThrows는 테스트 메서드 실행 중에 발생하리라 기대하는 예외 클래스를 넣어주면 된다. 정상적으로 테스트 메서드를 마치면 테스트가 실패하고, assertThrows에서 지정한 예외가 던져지면 테스트가 성공한다. 예외가 반드시 발생해야 하는 경우를 테스트하고 싶을 때 유용하게 쓸 수 있다.
그런데 이 테스트를 실행시키면 당연히 실패한다. get()
메서드에서 쿼리 결과의 첫 번째 row를 가져오게 하는 rs.next()
를 실행할 때 가져올 로우가 없다는 SQLException
이 발생할 것이다. 아직 UserDao
코드를 수정하지 않았기 때문이다.
이 테스트가 성공하도록 get()
메서드 코드를 수정하는 것이다. 코드를 수정하고 나서 테스트가 성공한다면 원하는 기능을 가진 코드가 제대로 만들어졌다고 보면 된다.
User
는 null
상태로 초기화해놓는다.id
를 조건으로 한 쿼리의 결과가 있으면 User
오브젝트를 만들고 값을 넣어준다.User
는 null
상태 그대로일 것이다. 이를 확인해서 예외를 던져준다.get()
메서드public User get(String id) throws SQLException {
...
ResultSet rs = ps.executeQuery();
User user = null;
if (rs.next()) {
user = new User();
user.setId(rs.getString("id"));
user.setName(rs.getString("name"));
user.setPassword(rs.getString("password"));
}
rs.close();
ps.close();
c.close();
if (user == null) throw new EmptyResultDataAccessException(1);
return user;
}
이제 테스트를 실행해보면 분명히 성공할 것이다. 테스트를 할 때는 기존에 만들어둔 테스트도 함께 실행한다. 만약 get()
메서드에 예외를 발생시키는 기능을 추가하다가 기존 코드를 잘못 건드렸을 경우 addAndGet()
테스트가 실패할테니, 이를 확인하고 다시 get()
메서드의 오류를 잡아주면 된다. 최종적으로 모든 테스트가 성공하면, 새로 추가한 기능도 정상적으로 동작하고 기존의 기능에도 영향을 주지 않았다는 확신을 얻을 수 있다.
이렇게 DAO의 메서드에 대한 포괄적인 테스트를 만들어두는 것이 훨씬 안전하고 유용하다. 특히 평소에는 정상적으로 잘 동작하는 것처럼 보이지만 만약 엉뚱하게 동작하는 코드를 만들었는데 테스트도 안 해봤다면, 나중에 문제가 발생했을 때 원인을 찾기 힘들어서 고생하게 될지도 모른다. 종종 단순하고 간단한 테스트가 치명적인 실수를 피할 수 있게 해주기도 한다.
개발자가 테스트를 직접 만들 때 성공하는 테스트만 골라서 만드는 실수를 한다. 그래서 테스트를 작성할 때 문제가 될 만한 상황이나, 입력 값 등은 피해서 코드를 만든다. 이건 테스트 코드를 통한 자동 테스트뿐 아니라, UI를 통한 수동 테스트를 할 때도 빈번하게 발생하는 문제다.
❗️ 개발자들의 "내 PC에서는 잘 되는데"라는 변명은 사실 개발자 PC에서 테스트할 때는 예외적인 상황은 모두 피하고 정상적인 케이스만 테스트해봤다는 뜻이다. 이런 이유 때문에 QA팀이나 고객의 인수담당자에 의해 꼼꼼하게 준비된 시나리오를 따라 다양한 경우에 대한 전문적인 테스트가 수행될 필요가 있다.
하지만 개발자도 조금만 신경쓰면 자신이 만든 코드에서 발생할 수 있는 다양한 상황과 입력 값을 고려하는 포괄적인 테스트를 만들 수 있다. 테스트를 작성할 때 부정적인 케이스를 먼저 만드는 습관을 들이는 게 좋다. get()
메서드의 경우라면, 존재하지 않는 id가 주어졌을 때는 어떻게 반응할지를 먼저 결정하고, 이를 확인할 수 있는 테스트를 먼저 만들려고 한다면 예외적인 상황을 빠뜨리지 않는 꼼꼼한 개발이 가능하다.
get()
메서드의 예외 테스트를 만드는 과정을 생각해보자. 작업한 순서를 보면, 테스트를 먼저 만들어 테스트가 실패하는 것을 보고 나서 UserDao
의 코드에 손을 대기 시작했다. 테스트할 코드보다 테스트 코드를 먼저 만드는 것이 이상하다고 생각할지 모르지만, 많은 전문적인 개발자가 이런 개발 방법을 적극적으로 사용하고 있다.
우리가 한 작업을 돌이켜보자. 가장 먼저 '존재하지 않는 id
로 get()
메서드를 실행하면 특정한 예외가 던져져야 한다'는 식으로 만들어야 할 기능을 결정했다. 그러고 나서 UserDao
코드를 수정하는 대신 getUserFailure()
테스트를 먼저 만들었다. 만들어진 코드를 보고 이것을 어떻게 테스트할까라고 생각하면서 getUserFailure()
를 만든 것이 아니라, 추가하고 싶은 기능을 코드로 표현하려고 했기 때문에 가능했다. getUserFailure()
테스트에는 만들고 싶은 기능에 대한 조건과 행위, 결과에 대한 내용이 잘 표현되어 있다.
이런 식으로 추가하고 싶은 기능을 일반 언어가 아닌 테스트 코드로 표현해서, 마치 코드로 된 설계문서처럼 만들어놓은 것이라고 생각해보자. 그러고 나서 실제 기능을 가진 애플리케이션 코드를 만들고 나면, 바로 이 테스트를 실행해서 설계한 대로 코드가 동작하는지를 빠르게 검증할 수 있다.
만약 테스트가 실패하면 이때는 설계한 대로 코드가 만들어지지 않았음을 바로 알 수 있다. 그리고 문제가 되는 부분이 무엇인지에 대한 정보도 테스트 결과를 통해 얻을 수 있다. 다시 코드를 수정해서 결국 테스트가 성공한다면, 그 순간 코드 구현과 테스트라는 두 가지 작업이 동시에 끝나는 것이다.
만들고자 하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증도 해줄 수 있도록 테스트 코드를 먼저 만들고, 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법을 테스트 주도 개발(TDD, Test Driven Development)이라고 한다. 또는 테스트를 코드보다 먼저 작성한다고 해서 테스트 우선 개발(Test First Development)이라고도 한다. "실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다"는 것이 TDD의 기본 원칙이다. 이 원칙을 따랐다면 만들어진 모든 코드는 빠짐없이 테스트로 검증된 것이라고 볼 수 있다.
개발을 먼저 하면 생기는 문제는, 코드를 만들고 나서 시간이 많이 지나면 테스트를 만들기가 귀찮아진다는 점이다. 또, 작성한 코드가 많기 때문에 무엇을 테스트해야 할지 막막할 수도 있다.
TDD에서는 테스트를 작성하고 이를 성공시키는 코드를 만드는 작업의 주기를 가능한한 짧게 가져가도록 권장한다. 테스트를 반나절 동안이나 만들고 오후 내내 테스트를 통과시키는 코드를 만드는 식의 개발은 그다지 좋은 방법이 아니다.
TDD를 하면 자연스럽게 단위 테스트를 만들 수 있다. 빠르게 자동으로 실행할 수 있는 단위 테스트가 아니고서는 이런 식의 개발은 거의 불가능하다. 테스트할 때마다 서버를 띄우고 재배치하는 시간이 필요하다면, 테스트를 자주 하기 귀찮을 것이다.
테스트는 코드를 작성한 후에 가능한 빨리 실행할 수 있어야 한다. 그러려면 테스트 없이 한 번에 너무 많은 코드를 만드는 것은 좋지 않다. 테스트를 먼저 만들어두면 코딩이 끝나자마자 바로 테스트를 실행할 수 있으니 가장 좋은 방법이다. 하지만 이런 방법이 불편하다면 일정 분량의 코딩을 먼저 해놓고 빠른 시간 안에 테스트 코드를 만들어 테스트해도 상관없다.
혹시 테스트를 만들고 자주 실행하면 개발이 지연되지 않을까 염려할 지도 모르겠지만, 그렇지 않다. 테스트는 애플리케이션 코드보다 상대적으로 작성하기 쉬운데다 각 테스트가 독립적이기 때문에, 코드의 양에 비해 작성하는 시간은 얼마 걸리지 않는다. 게다가 테스트 덕분에 오류를 빨리 작아낼 수 있어서 전체적인 개발 속도는 오히려 빨라진다.
이제 테스트 코드를 리팩토링해보자. 테스트 코드 자체가 이미 자신에 대한 테스트이기 때문에 테스트 결과가 일정하게 유지된다면 얼마든지 리팩토링을 해도 좋다.
UserDaoTest
에서 기계적으로 반복되는 부분UserDao
를 가져오는 부분ApplicationContext context = new GenericXmlApplicationContext(
"applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
중복된 코드는 별도의 메서드로 뽑아내는 것이 가장 손쉬운 방법이다. JUnit 프레임워크는 테스트를 실행할 때마다 반복되는 준비 작업을 별도의 메서드에 넣게 해주고, 이를 매번 테스트 메서드를 실행하기 전에 먼저 실행시켜주는 @BeforeEach
애노테이션이 있다.
📌 책은 JUnit4 기준으로 @Before을 사용하지만, 여기선 JUnit5의 @BeforeEach를 사용한다.
setUp()
이라는 이름의 메서드를 만들고 @BeforeEach
애노테이션을 추가해준 뒤, 테스트 메서드에서 제거한 코드를 넣는다.@BeforEach
는 JUnit이 제공하는 애노테이션으로 @Test
메서드가 실행되기 전에 먼저 실행돼야 하는 메서드를 정의한다. setUp()
메서드에서 만드는 오브젝트를 테스트 메서드에서 사용할 수 있도록 인스턴스 변수로 선언한다. dao
변수가 setUp()
메서드의 로컬 변수로 되어 있기 때문이다.import org.junit.jupiter.api.BeforeEach;
...
class UserDaoTest {
private UserDao dao;
@BeforeEach
void setUp() {
ApplicationContext context = new GenericXmlApplicationContext(
"applicationContext.xml");
this.dao = context.getBean("userDao", UserDao.class);
}
...
@Test
void addAndGet() throws SQLException {
...
}
@Test
void count() throws SQLException {
...
}
@Test
void getUserFailure() throws SQLException{
...
}
}
이렇게 수정해도 이전과 마찬가지로 테스트가 모두 성공할 것이다. 프레임워크는 스스로 제어권을 가지고 주도적으로 동작하고, 개발자가 만든 코드는 프레임워크에 의해 수동적으로 실행된다. 그래서 프레임워크가 어떻게 사용할지를 잘 이해하고 있어야 한다.
👉 JUnit이 하나의 테스트 클래스를 가져와 테스트를 수행하는 방식
1. 테스트 클래스에서@Test
가 붙은public
이고void
형이며 파라미터가 없는 테스트 메서드를 모두 찾는다.
2. 테스트 클래스의 오브젝트를 하나 만든다.
3.@BeforeEach
가 붙은 메서드가 있으면 실행한다.
4.@Test
가 붙은 메서드를 하나 호출하고 테스트 결과를 저장해둔다.
5.@AfterEach
가 붙은 메서드가 있으면 실행한다.
6. 나머지 테스트 메서드에 대해 2~5번을 반복한다.
7. 모든 테스트의 결과를 종합해서 돌려준다.실제로는 이보다 더 복잡한데, 간단히 정리하면 위의 7단계를 거쳐서 진행된다고 볼 수 있다.
보통 하나의 테스트 클래스 안에 있는 테스트 메서드들은 공통적인 준비 작업과 정리 작업이 필요한 경우가 많다. 이런 작업들을 @BeforeEach
, @AfterEach
가 붙은 메서드에 넣어두면 JUnit이 자동으로 메서드를 실행해주니 매우 편리하다. 각 테스트 메서드에서 직접 setUp()
과 같은 메서드를 호출할 필요도 없다. 대신 @BeforeEach
나 @AfterEach
메서드를 테스트 메서드에서 직접 호출하지 않기 때문에 서로 주고받을 정보나 오브젝트가 있다면 인스턴스 변수를 이용해야 한다.
또 한 가지 꼭 기억해야 할 사항은 각 테스트 메서드를 실행할 때마다 테스트 클래스의 오브젝트를 새로 만든다는 점이다. 한번 만들어진 테스트 클래스의 오브젝트는 하나의 테스트 메서드를 사용하고 나면 버려진다.
그런데, 왜 이렇게 하는 것일까? JUnit 개발자는 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 확실히 보장해주기 위해 매번 새로운 오브젝트를 만들게 했다. 덕분에 인스턴스 변수도 부담없이 사용할 수 있다.
테스트 메서드의 일부에서만 공통적으로 사용되는 코드가 있다면 어떻게 해야 할까? 이때는 @BeforeEach
를 사용하기보다는, 일반적인 메서드 추출 방법을 써서 메서드를 분리하고 테스트 메서드에서 직접 호출해 사용하도록 만드는 편이 낫다. 아니면 아예 공통적인 특징을 지닌 테스트 메서드를 모아서 별도의 테스트 클래스로 만드는 방법도 생각해볼 수 있다.
테스트를 수행하는 데 필요한 정보나 오브젝트를 픽스처(fixture)라고 한다. 일반적으로 픽스처는 여러 테스트에서 반복적으로 사용되기 때문에 @BeforeEach
메서드를 이용해 생성해두면 편리하다. UserDaoTest
에선 dao
가 대표적인 픽스처다. 테스트 중에 add()
메서드에 전달하는 User
오브젝트들도 픽스처라고 볼 수 있다. 이 부분도 테스트 메서드에서 중복된 코드가 보이지만 getUserFailure()
테스트에서는 이 User
오브젝트가 사용되진 않는다. 그럼에도 이 User 오브젝트들은 @BeforeEach
에서 생성하도록 만드는 게 나을 것 같다. 앞으로 UserDao
의 기능이 계속 만들어질텐데 UserDao
에 대한 테스트라면 대부분 User 오브젝트를 사용할 것이기 때문이다.
user1
, user2
, user3
세 가지 인스턴스 변수를 선언한다.@BeforeEach
메서드에서 진행한다. @BeforeEach
메서드를 이용하자.class UserDaoTest {
private UserDao dao;
private User user1;
private User user2;
private User user3;
@BeforeEach
void setUp() {
...
this.user1 = new User("gyumee", "박성철", "springno1");
this.user2 = new User("leegw700", "이길원", "springno2");
this.user3 = new User("bumjin", "박범진", "springno3");
}
...
}
테스트도 어느 정도 정리했으니, 이젠 애플리케이션 컨텍스트 생성 방식에 대해 생각해보자. @BeforeEach
메서드가 테스트 메서드 개수만큼 반복되기 때문에 애플리케이션 컨텍스트도 세 번 만들어진다. 지금은 괜찮지만, 빈이 많아지고 복잡해지면 애플리케이션 컨텍스트 생성에 적지 않은 시간이 걸릴 수 있다. 애플리케이션 컨텍스트가 만들어질 때는 모든 싱글톤 빈 오브젝트를 초기화한다. 단순히 빈 오브젝트를 만드는 정도라면 상관없지만, 어떤 빈은 오브젝트가 생성될 때 자체적인 초기화 작업을 진행해서 제법 많은 시간을 필요로 하기 때문이다. 또 한 가지 문제는 애플리케이션 컨텍스트가 초기화될 때 어떤 빈은 독자적으로 많은 리소스를 할당하거나 독립적인 스레드를 띄우기도 한다는 점이다. 이런 경우에는 테스트를 마칠 때마다 애플리케이션 컨텍스트 내의 빈이 할당한 리소스 등을 깔끔하게 정리해주지 않으면 다음 테스트에서 새로운 애플리케이션 컨텍스트가 만들어지면서 문제를 일으킬 수도 있다.
테스트는 가능한 한 독립적으로 매번 새로운 오브젝트를 만들어서 사용하는 것이 원칙이다. 하지만 애플리케이션 컨텍스트처럼 생성에 많은 시간과 자원이 소모되는 경우에는 테스트 전체가 공유하는 오브젝트를 만들기도 한다. 이때도 테스트는 일관성 있는 실행 결과를 보장해야 하고, 테스트의 실행 순서가 결과에 영향을 미치지 않아야 한다. 애플리케이션 컨텍스트는 초기화되고 나면 내부의 상태가 바뀌는 일은 거의 없다. 빈은 싱글톤으로 만들었기 때문에 상태를 갖지 않는다. DB의 상태는 각 테스트에서 알아서 관리할 것이므로 문제가 되지 않는다. 따라서 애플리케이션 컨텍스트는 한 번만 만들고 여러 테스트가 공유해서 사용해도 된다.
문제는 JUnit이 매번 테스트 클래스의 오브젝트를 새로 만든다는 점이다. 따라서 여러 테스트가 함께 참조할 애플리케이션 컨텍스트를 오브젝트 레벨에 저장해두면 곤란하다. 그래서 JUnit은 테스트 클래스 전체에 걸쳐 딱 한 번만 실행되는 @BeforeClass
스태틱 메서드를 지원하지만, 이보다는 스프링이 직접 제공하는 애플리케이션 컨텍스트 테스트 지원 기능을 사용하는 것이 더 편리하다.
스프링은 JUnit을 이용하는 테스트 컨텍스트 프레임워크를 제공한다. 테스트 컨텍스트의 지원을 받으면 간단한 애노테이션 설정만으로 테스트에서 필요로 하는 애플리케이션 컨텍스트를 만들어서 모든 테스트가 공유하게 할 수 있다.
UserDaoTest
에 스프링의 텍스트 컨텍스트 프레임워크를 적용해보자.
@BeforeEach
메서드에서 애플리케이션 컨텍스트를 생성하는 다음 코드를 제거한다.
ApplicationContext context =
new GenericXmlApplicationContext("applicationContext.xml");
이 ApplicationContext
타입의 인스턴스 변수를 선언하고 스프링이 제공하는 @Autowired
애노테이션을 붙여준다.
@Authwired
: 테스트 오브젝트가 만들어지고 나면 스프링 테스트 컨텍스트에 의해 자동으로 값이 주입된다.클래스 레벨에 @ExtendWith
와 @ContextConfiguration
애노테이션을 추가해준다.
@ExtendWith
: 스프링의 테스트 컨텍스트 프레임워크의 JUnit 확장기능 지정@ContextConfiguration
: 테스트 컨텍스트가 자동으로 만들어줄 애플리케이션 컨텍스트의 위치 지정📌 책은 JUnit4의
@RunWith
를 사용했지만, 여기는 JUnit5의@ExtendWith
를 사용한다.
참고 : https://reiphiel.tistory.com/entry/junit5-features2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/applicationContext.xml")
class UserDaoTest {
@Autowired
private ApplicationContext context;
...
@BeforeEach
void setUp() {
this.dao = this.context.getBean("userDao", UserDao.class);
...
}
그런데 인스턴스 변수인 context
는 어디에서도 초기화해주는 코드가 없는데 테스트는 아무런 문제 없이 성공적으로 끝난다. 스프링 컨텍스트 프레임워크의 JUnit 확장기능이 약간의 마법을 부린 것이다.
@ExtendWith
는 JUnit 프레임워크의 테스트 실행 방법을 확장할 때 사용하는 애노테이션이다. SpringExtension
라는 JUnit용 테스트 컨텍스트 프레임워크 확장 클래스를 지정해주면 JUnit이 테스트를 진행하는 중에 테스트가 사용할 애플리케이션 컨텍스트를 만들고 관리하는 작업을 진행해준다.
@ContextConfiguration
은 자동으로 만들어줄 애플리케이션 컨텍스트의 설정파일 위치를 지정한 것이다.
과연 어떤 일이 일어나고 있는지 확인해보자. setUp()
메서드에 다음 두 줄을 추가하고 테스트를 다시 실행해보자.
@BeforeEach
void setUp() {
System.out.println(this.context);
System.out.println(this);
setUp()
메서드는 @BeforeEach
가 붙어 있으니 매 테스트 메서드가 실행되기 전에 한 번씩 실행된다. 이때 인스턴스 변수인 context
와 테스트 오브젝트 자신인 this
를 콘솔에 출력하게 한다.
org.springframework.context.support.GenericApplicationContext@704deff2, started on Sun Oct 31 20:38:57 KST 2021
com.springbook.user.dao.UserDaoTest@6692b6c6
org.springframework.context.support.GenericApplicationContext@704deff2, started on Sun Oct 31 20:38:57 KST 2021
com.springbook.user.dao.UserDaoTest@690bc15e
org.springframework.context.support.GenericApplicationContext@704deff2, started on Sun Oct 31 20:38:57 KST 2021
com.springbook.user.dao.UserDaoTest@6c6333cd
출력된 context
와 this
의 오브젝트 값을 보자.
context
는 세 번 모두 동일하다.UserDaoTest
의 오브젝트는 매번 주소 값이 다르다.그렇다면 context
변수에 어떻게 애플리케이션 컨텍스트가 들어 있는 것일까? 스프링의 JUnit 확장기능은 테스트가 실행되기 전에 딱 한 번만 애플리케이션 컨텍스트를 만들어두고, 테스트 오브젝트가 만들어질 때마다 특별한 방법을 이용해 애플리케이션 컨텍스트 자신을 테스트 오브젝트의 특정 필드에 주입해주는 것이다. 일종의 DI라고 볼 수 있는데, 애플리케이션 오브젝트 사이의 관계를 관리하기 위한 DI와는 조금 성격이 다르다.
아무튼 스프링이 애플리케이션 컨텍스트를 테스트 개수에 상관없이 한 번만 만들어서 공유하게 해줬기 때문에 테스트 수행 속도는 매우 빨라진다. 첫 번째 테스트가 실행될 때 최초로 애플리케이션 컨텍스트가 처음 만들어지면서 가장 오랜 시간이 소모되고, 그 다음부터는 이미 만들어진 애플리케이션 컨텍스트를 재사용할 수 있기 때문에 테스트 실행 시간이 매우 짧아지는 것이다.
여러 개의 테스트 클래스가 있는데 모두 같은 설정파일을 가진 애플리케이션 컨텍스트를 사용한다면, 스프링은 테스트 클래스 사이에서도 애플리케이션 컨텍스트를 공유하게 해준다. 다음과 같이 두 개의 테스트 클래스가 같은 설정파일을 사용하는 경우에는 테스트 수행 중에 다 한 개의 애플리케이션 컨텍스트만 만들어진다.
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/applicationContext.xml")
class UserDaoTest { ... }
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/applicationContext.xml")
class GroupDaoTest { ... }
따라서 수백 개의 테스트 클래스를 만들었는데 모두 같은 설정파일을 사용한다고 해도 테스트 전체에 걸쳐 단 한 개의 애플리케이션 컨텍스트만 만들어져 사용된다. 이 덕분에 테스트 성능이 대폭 향상된다.
📌 물론 테스트 클래스마다 다른 설정파일을 사용하도록 만들어도 되고, 몇 개의 테스트에서만 다른 설정파일을 사용할 수도 있다. 스프링은 설정파일이 종류만큼 애플리케이션 컨텍스트를 만들고, 같은 설정파일을 지정한 테스트에서는 이를 공유하게 해준다.
@Autowired
@Autowired
는 스프링의 DI에 사용되는 특별한 애노테이션이다. @Autowired
가 붙은 인스턴스 변수가 있으면, 테스트 컨텍스트 프레임워크는 변수 타입과 일치하는 컨텍스트 내의 빈을 찾고, 일치하는 빈이 있으면 인스턴스 변수에 주입해준다. 메서드가 없어도 주입이 가능하다. 또 별도의 DI 설정 없이 필드의 타입정보를 이용해 빈을 자동으로 가져올 수 있는데, 이런 방법을 타입에 의한 자동와이어링이라고 한다.
그런데 앞에서 만든 테스트 코드에서는 applicationContext.xml
파일에 정의된 빈이 아니라, ApplicationContext
라는 타입의 변수에 @Autowired
를 붙였는데 애플리케이션 컨텍스트가 DI됐다. 스프링 애플리케이션 컨텍스트는 초기화할 때 자기 자신도 빈으로 등록하기 때문에 스프링 애플리케이션 컨텍스트에는 ApplicationContext
타입의 빈이 존재하는 셈이고 DI도 가능한 것이다.
@Autowired
를 이용해 애플리케이션 컨텍스트가 갖고 있는 빈을 DI 받을 수 있다면 굳이 컨텍스트를 가져와 getBean()
을 사용하는 것이 아니라, 아예 UserDao
빈을 직접 DI 받을 수도 있다.
...
class UserDaoTest {
@Autowired
UserDao dao;
애플리케이션 컨텍스트를 DI 받아서 다시 DL 방식으로 UserDao
를 가져올 때보다 테스트 코드가 더 깔끔해졌다. @Autowired
를 지정하기만 하면 어떤 빈이든 다 가져올 수 있다. 또한, 변수에 할당 가능한 타입을 가진 빈을 자동으로 찾기 때문에 인터페이스 타입으로 변수를 선언해도 된다. 단, @Autowired
는 같은 타입의 빈이 두 개 이상 있는 경우에는 변수의 이름과 같은 이름의 빈이 있는지 확인한다. 변수 이름으로도 빈을 찾을 수 없는 경우에는 예외가 발생한다.
XML에 dataSource
라는 이름으로 등록한 SimpleDriverDataSource
타입의 빈을 가져오고 싶다면, 다음과 같은 인스턴스 변수를 추가해주기만 하면 된다.
@Autowired
SimpleDriverDataSource dataSource;
그런데 SimpleDriverDataSource
타입의 변수로 선언하는 방법과 DataSource
타입으로 선언하는 방법 중 어떤 것이 나을까? 그건 테스트에서 빈을 어떤 용도로 사용하느냐에 따라 다르다. 단순히 DataSource에 정의된 메서드를 테스트에서 사용하고 싶은 경우라면 DataSource 타입으로 받는 게 좋다. 반면에 테스트에서 SimpleDriverDataSource라는 타입의 오브젝트 자체에 관심이 있는 경우가 있을 수도 있다. 이때는 SimpleDriverDataSource 타입으로 선언해야 한다. 예를 들어 SimpleDriverDataSource 클래스의 메서드를 직접 이용해서 테스트를 해야 할 경우도 있다.
테스트는 필요하다면 얼마든지 애플리케이션 클래스와 밀접한 관계를 맺고 있어도 상관없다. 개발자가 만드는 테스트는 코드 내부구조와 설정 등을 알고 있고 의도적으로 그 내용을 검증해야 할 필요가 있기 때문이다. 하지만 꼭 필요하지 않다면 테스트에서도 가능한 한 인터페이스를 사용해서 애플리케이션 코드와 느슨하게 연결해두는 편이 좋다.
UserDao
와 DB 커넥션 생성 클래스 사이에는 DataSource
라는 인터페이스를 뒀다. 그래서 코드의 수정 없이도 얼마든지 의존 오브젝트를 바꿔가며 사용할 수 있다. 그런데 만약 "절대로 DataSource
의 구현 클래스를 바꾸지 않을 것이다." 라고 한다면 굳이 DataSource
를 사용하고 DI를 통해 주입해주는 방식을 이용해야 하는가?
소프트웨어 개발에서 절대로 바뀌지 않는 것은 없다.
클래스의 구현 방식은 바뀌지 않는다고 하더라도 인터페이스를 두고 DI를 적용하게 해두면 다른 차원의 서비스 기능을 도입할 수 있기 때문이다.
효율적인 테스트를 손쉽게 만들기 위해서라도 DI를 적용해야 한다.
UserDao
에 DI 컨테이너가 의존관계 주입에 사용하도록 수정자 메서드를 만들어뒀다. 이 수정자 메서드는 평범한 자바 메서드이므로 테스트 코드에서도 사용 가능하다. 따라서 테스트 코드 내에서 UserDao
가 사용할 DataSource
오브젝트를 직접 DI 해도 된다.
애플리케이션이 사용할 applicationContext.xml
에 정의된 DataSource
빈은 서버의 DB 풀 서비스와 연결해서 운영용 DB 커넥션을 돌려주도록 만들어져 있다고 해보자. 이런 경우엔 applicationContext.xml
설정에서 DataSource
빈을 수정하는 것보다, 테스트 코드에 의한 DI를 이용해서 테스트 중에 DAO가 사용할 DataSource
오브젝트를 바꿔주는 방법을 이용하면 된다.
테스트 내 @BeforeEach
메서드에서 UserDao
가 사용할 DataSource
오브젝트를 직접 생성한다.
DataSource
구현 클래스는 스프링이 제공하는 가장 빠른 DataSource
인 SingleConnectionDataSource를 사용한다. testdb
로 변경한다. 애플리케이션 컨텍스트에서 가져온 dao
오브젝트의 setDataSource()
메서드를 통해 수동으로 DI 해준다.
UserDao
가 테스트용 DataSource
를 사용해서 동작하게 된다. 📌
SingleConnectionDataSource
는 DB 커넥션을 하나만 만들어두고 계속 사용하기 때문에 매우 빠르다. 다중 사용자 환경에서는 사용할 수 없겠지만 순차적으로 진행되는 테스트에서는 문제없다.
UserDaoTest
...
@DirtiesContext
class UserDaoTest {
@Autowired
UserDao dao;
@BeforeEach
void setUp() {
...
DataSource dataSource = new SingleConnectionDataSource(
"jdbc:mysql://localhost/testdb", "root", "root", true);
dao.setDataSource(dataSource);
}
@DirtiesContext
: 테스트 메서드에서 애플리케이션 컨텍스트의 구성이나 상태를 변경한다는 것을 테스트 컨텍스트 프레임워크에 알려준다.이 방법의 장점은 XML 설정파일을 수정하지 않고도 테스트 코드를 통해 오브젝트 관계를 재구성할 수 있다는 것이다. 예외적인 상황을 만들기 위해 일부러 엉뚱한 오브젝트를 넣거나, 위와 같이 테스트용으로 준비된 오브젝트를 사용하게 할 수 있다.
하지만 이 방식은 매우 주의해서 사용해야 한다. 이미 애플리케이션 컨텍스트에서 applicationContext.xml
파일의 설정정보를 따라 구성한 UserDao
오브젝트를 가져와 의존관계를 강제로 변경했기 때문이다. 스프링 테스트 컨텍스트 프레임워크를 적용했다면 애플리케이션 컨텍스트는 테스트 중에 딱 한 개만 만들어지고 모든 테스트에서 공유해서 사용한다. 따라서 애플리케이션 컨텍스트의 구성이나 상태를 테스트 내에서 변경하지 않는 것이 원칙이다. 한 번 변경하면 나머지 모든 테스트를 수행하는 동안 변경된 애플리케이션 컨텍스트가 계속 사용될 것이다.
그래서 @DirtiesContext
애노테이션을 사용해 스프링의 테스트 컨텍스트 프레임워크에게 해당 클래스의 테스트에서 애플리케이션 컨텍스트의 상태를 변경한다는 것을 알려준다. 테스트 컨텍스트는 이 애노테이션이 붙은 테스트 클래스에는 애플리케이션 컨텍스트 공유를 허용하지 않는다. 테스트 메서드를 수행하고 나면 매번 새로운 애플리케이션 컨텍스트를 만들어서 다음 테스트가 사용하게 해준다. 테스트 중에 변경한 컨텍스트가 뒤의 테스트에 영향을 주지 않게 해준다.
📌 메서드 레벨의
@DirtiesContext
사용하기
@DirtiesContext
는 메서드에도 적용할 수 있다. 하나의 메서드에서만 컨텍스트 상태를 변경한다면 메서드 레벨에 붙여주는 편이 낫다. 해당 메서드의 실행이 끝나고 나면 이후에 진행되는 테스트를 위해 변경된 애플리케이션 컨텍스트는 폐기되고 새로운 애플리케이션 컨텍스트가 만들어진다.
테스트 코드에서 빈 오브젝트에 수동으로 DI 하는 방법은 코드가 많아져 번거롭고 애플리케이션 컨텍스트도 매번 새로 만들어야 하는 부담이 있다.
그래서 테스트에서 사용될 DataSource
클래스가 빈으로 정의된 테스트 전용 설정파일을 따로 만들어두는 방법을 이용해도 된다. 즉 두 가지 종류의 설정파일을 만들어서 하나에는 서버에서 운영용으로 사용할 DataSource
를 빈으로 등록해두고, 다른 하나에는 테스트에 적합하게 준비된 DB를 사용하는 가벼운 DataSource
가 빈으로 등록되게 만드는 것이다. 그리고 테스트에서는 항상 테스트 전용 설정파일만 사용하게 해주면 된다.
applicationContext.xml
을 복사해서 test-applicationContext.xml
라고 만든다. UserDaoTest
의 @ContextConfiguration
애노테이션에 있는 locations
엘리먼트의 값을 새로 만든 테스트용 설정파일로 변경한다. <bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
<property name="driverClass" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost/testdb"/>
<property name="username" value="root"/>
<property name="password" value="root"/>
</bean>
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
class UserDaoTest {
이제 애플리케이션 컨텍스트도 한 개만 만들어서 모든 테스트에서 공유할 수 있다. 설정파일을 테스트에 맞게 수정만 하면 테스트에 적합한 오브젝트 의존관계를 만들어 사용할 수 있게 됐다.
마지막으로 살펴볼, DI 를 테스트에 이용하는 방법은 아예 스프링 컨테이너를 사용하지 않고 테스트를 만드는 것이다. UserDao
나 DataSource
구현 클래스는 스프링 API를 직접 사용한다거나 애플리케이션 컨텍스트를 이용하는 코드는 존재하지 않는다. 스프링 DI 컨테이너에 의존하지 않는다는 말이다. 따라서 원한다면 스프링 컨테이너를 이용하는 대신, 테스트 코드에서 직접 오브젝트를 만들고 DI 해서 사용해도 된다.
UserDaoTest
는 사실 UserDao
코드가 DAO로서 DB에 정보를 잘 등록하고 잘 가져오는지만 확인하면 된다. 스프링 컨테이너 없이 테스트 코드의 수동 DI만을 이용해 만들어진 테스트 코드를 살펴보자. @ExtendWith
도 @Autowired
도 사용하지 않았다. 대신 @BeforeEach
메서드에서 직접 UserDao
의 오브젝트를 생성하고, 테스트용 DataSource
오브젝트를 만들어 직접 DI 해줬다.
class UserDaoTest {
UserDao dao;
...
@BeforeEach
void setUp() {
...
dao = new UserDao();
DataSource dataSource = new SingleConnectionDataSource(
"jdbc:mysql://localhost/testdb", "root", "root", true);
dao.setDataSource(dataSource);
}
테스트를 위한 DataSource
를 직접 만드는 번거로움은 있지만 애플리케이션 컨텍스트를 아예 사용하지 않으니 코드는 더 단순해지고 이해하기 편해졌다. 애플리케이션 컨텍스트가 만들어지는 번거로움이 없어졌으니 그만큼 테스트 시간도 절약할 수 있다. 하지만 JUnit은 매번 새로운 테스트 오브젝트를 만들기 때문에 매번 새로운 UserDao
오브젝트가 만들어진다는 단점도 있다.
이 테스트는 UserDao
가 스프링의 API에 의존하지 않고 자신의 관심에만 집중해서 깔끔하게 만들어진 코드이기 때문에 세 개의 UserDao
테스트를 완벽하게 통과한다. 바로 이런 가볍고 깔끔한 테스트를 만들 수 있는 이유도 DI를 적용했기 때문이다. DI 컨테이너나 프레임워크는 DI를 편하게 적용하도록 도움을 줄 뿐, 컨테이너가 DI를 가능하게 해주는 것은 아니다.
👉 침투적 기술과 비침투적 기술
침투적(invasive) 기술은 기술을 적용했을 때 애플리케이션 코드에 기술 관련 API가 등장하거나, 특정 인터페이스나 클래스를 사용하도록 강제하는 기술을 말한다. 침투적 기술을 사용하면 애플리케이션 코드가 해당 기술에 종속되는 결과를 가져온다. 반면에 비침투적(noninvasive)인 기술은 애플리케이션 로직을 담은 코드에 아무런 영향을 주지 않고 적용이 가능하다. 따라서 기술에 종속적이지 않은 순수한 코드를 유지할 수 있게 해준다. 스프링은 이런 비침투적인 기술의 대표적인 예다. 그래서 스프링 컨테이너 없는 DI 테스트도 가능한 것이다.
어디에 DI를 적용할지 고민되는 경우, 효과적인 테스트를 만들기 위해서는 어떤 필요가 있을지를 생각해보면 도움이 된다. 두 개의 모듈이 강하게 결합되어 있어 DI가 불가능한 구조로 만든다면 테스트할 때 불편해진다거나, 자동화된 테스트가 아예 불가능하지 않을까 의심해보자. 일반적으로 테스트하기 좋은 코드가 좋은 코드일 가능성이 높다.
그렇다면 DI를 테스트에 이용하는 세 가지 방법 중 어떤 것을 선택해야 할까?
항상 스프링 컨테이너 없이 테스트할 수 있는 방법을 가장 우선적으로 고려하자.
여러 오브젝트와 복잡한 의존관계를 갖고 있는 오브젝트를 테스트해야 할 경우
테스트 설정을 따로 만들었다고 하더라도 때로는 예외적인 의존관계를 강제로 구성해서 테스트해야 할 경우가 있다.
@DirtiesContext
애노테이션을 붙인다.일반적으로 애플리케이션 개발자는 자신이 만들고 있는 코드에 대한 테스트만 작성하면 된다. 하지만 때로는 자신이 만들지 않은 프레임워크나 다른 개발팀에서 만들어서 제공한 라이브러리 등에 대해서도 테스트를 작성해야 한다. 이런 테스트를 학습 테스트(learning test)라고 한다.
학습 테스트의 목적은 자신이 사용할 API나 프레임워크의 기능을 테스트로 보면서 사용 방법을 익히려는 것이다. 따라서 테스트이지만 프레임워크나 기능에 대하 검증이 목적이 아니다. 오히려 자신이 테스트를 만들려고 하는 기술이나 기능에 대해 얼마나 제대로 이해하고 있는지, 그 사용 방법을 바로 알고 있는지를 검증하려는 게 목적이다. 또, 테스트 코드를 작성해보면서 빠르고 정확하게 사용법을 익히는 것도 학습 테스트를 작성하는 하나의 목적이다.
저자는 새로운 프레임워크를 사용하게 되거나 새로운 기술을 공부할 때는 항상 테스트 코드를 먼저 만들어본다. 테스트 코드를 만드는 과정을 통해 API의 사용 방법도 익히고 저자가 가진 기술에 대한 지식도 검증할 수 있다. 학습 테스트는 테스트 대상보다는 테스트 코드 자체에 관심을 갖고 만들어야 한다.
다양한 조건에 따른 기능을 손쉽게 확인해볼 수 있다.
예제를 만들면서 학습하는 것은 수동 테스트와 성격이 비슷하다. 다양한 조건에 따라 수동으로 값을 입력하거나 코드를 계속 수정해가며 예제를 다시 실행해야 하고, 결과도 콘솔이나 화면에 나타내주는 방법밖에 없다. 반면에 학습 테스트는 자동화된 테스트 코드로 만들어지기 때문에 다양한 조건에 따라 기능이 어떻게 동작하는지 빠르게 확인할 수 있다.
학습 테스트 코드를 개발 중에 참고할 수 있다.
수동으로 예제를 만드는 방법은 코드를 계속 수정해가면서 기능을 확인해보기 때문에 결국 최종 수정한 예제 코드만 남아 있다. 반면에 학습 테스트는 다양한 기능과 조건에 대한 테스트 코드를 개별적으로 만들고 남겨둘 수 있다. 이렇게 테스트로 새로운 기술의 다양한 기능을 사용하는 코드를 만들어두면 실제 개발에서 샘플 코드로 참고할 수 있다. 복잡한 기능이라면 테스트에 관련된 설정파일도 만들어질 것이고, 초기화는 어떻게 하는지 API 호출 방법은 어떤 것인지, 결과는 어떻게 가져와야 하는지에 대한 샘플 코드가 테스트 안에 모두 만들어진다. 아직 익숙하지 않은 기술을 사용해야 하는 개발자에게는 이렇게 미리 만들어진 다양한 기능에 대한 테스트 코드가 좋은 참고 자료가 된다.
📌 저자는 새로운 프레임워크나 기술을 사용할 때면, 먼저 팀원들과 함께 학습 테스트를 만들면서 사용법을 익힌다. 그리고 이렇게 만든 학습 테스트를 애플리케이션 테스트 패키지의 일부로 추가해둔다. 개발자들은 필요하다면 언제든지 학습 테스트로 만들었던 코드를 참고해볼 수 있다. 또는 좀 더 나은 사용 방법을 발견했다면 학습 테스트의 코드를 수정해서 다른 개발자와 공유할 수 있다.
프레임워크나 제품을 업그레이드할 때 호환성 검증을 도와준다.
요즘은 오픈소스 프레임워크뿐 아니라 상용 제품도 인터넷을 통해 자주 업데이트가 공개된다. 문제는 이렇게 새로운 버전으로 업그레이드를 할 때 API 사용법에 미묘한 변화가 생긴다거나, 기존에는 잘 동작하던 기능에 문제가 발생할 수도 있다는 점이다. 보통 마이너 버전 업그레이드는 버그 수정이나 성능 개선 같은 미미한 수정인 경우가 많으므로 상대적으로 부담 없이 업그레이드 하기도 하는데, 그 과정에서 버그가 추가된 경우가 가끔 있어서 업그레이드 후에 시스템이 미묘한 문제를 일으킬 수 있다. 여기서 기존에 사용하던 API가 기능에 문제가 없다는 사실을 미리 확인해볼 수 있는 방법으로 학습 테스트를 이용하는 방법이 있다. 학습 테스트에 애플리케이션에서 자주 사용하는 기능에 대한 테스트를 만들어놓았다면 새로운 버전의 프레임워크나 제품을 학습 테스트에만 먼저 적용해 본다. 버그가 있어서 테스트가 실패하면 업그레이드 일정을 늦추거나, API의 사용 방법에 변화가 발생한 경우라면 그에 맞춰서 애플리케이션 코드를 수정할 계획을 세울 수 있을 것이다. 물론 애플리케이션 개발에 사용하는 주요 기능에 대한 학습 테스트를 충분히 만들어뒀어야 가능하다.
테스트 작성에 대한 좋은 훈련이 된다.
개발자가 테스트를 작성하는 데 아직 충분히 훈련되어 있지 않거나 부담을 갖고 있다면, 먼저 학습 테스트를 작성해보면서 테스트 코드 작성을 연습할 수 있다. 또한 학습 테스트는 한두 가지 간단한 기능에만 초점을 맞추면 되기 때문에 테스트도 대체로 단순하다. 따라서 애플리케이션 개발 중에 작성하는 테스트보다는 한결 작성하기가 수월하고 부담도 적다. 또는 새로운 테스트 방법을 연구하는 데도 도움이 된다. 기술에 따라서 테스트가 까다로운 것도 있는데 이럴 때 먼저 학습 테스트를 만들어보면서 간결한 테스트 작성 방법을 연구해보면 도움이 된다.
새로운 기술을 공부하는 과정이 즐거워진다.
책이나 레퍼런스 문서 등을 그저 읽기만 하는 공부는 쉽게 지루해져서 학습의 능률도 떨어뜨린다. 그에 비해 테스트 코드를 만들면서 하는 학습은 흥미롭고 재미있다.
학습 테스트는 당장 적용할 일부 기능의 사용법을 익히기 위해서만이 아니라 새로운 프레임워크나 기술을 전반적으로 공부하는 과정에서도 유용하다. 스프링 레퍼런스 메뉴얼이나 관련 서적을 가지고 공부하면서 잘 이해가 안된다거나 자세한 사용 방법이 궁금하다면 직접 학습 테스트로 만들어보는 것도 도움이 된다.
스프링 학습 테스트를 만들 때 참고할 수 있는 가장 좋은 소스는 바로 스프링 자신에 대한 테스트 코드다. 스프링은 꼼꼼하게 테스트를 만들어가며 개발해온 프레임워크다. 거의 모든 기능에 대해 방대한 양의 테스트가 만들어져 있다. 스프링 테스트를 잘 살펴보면 레퍼런스 문서에서는 미처 설명되지 않았던 중요한 정보도 많이 얻을 수 있다. 또, 테스트 작성 방법에 대한 좋은 팁을 얻을 수 있을 것이다.
JUnit은 테스트 메서드를 수행할 때마다 새로운 오브젝트를 만든다고 했다. 확인하기 위해 JUnit에 대한 학습 테스트를 만들어보자. JUnit으로 만드는 JUnit 자신에 대한 테스트다.
package com.springbook.learningtest.junit;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.assertThat;
import org.junit.jupiter.api.Test;
public class JUnitTest {
static JUnitTest testObject;
@Test
void test1() {
assertThat(this, is(not(sameInstance(testObject))));
testObject = this;
}
@Test
void test2() {
assertThat(this, is(not(sameInstance(testObject))));
testObject = this;
}
@Test
void test3() {
assertThat(this, is(not(sameInstance(testObject))));
testObject = this;
}
}
not()
: 뒤에 나오는 결과를 부정하는 매처다.
is()
는 equals()
비교를 해서 같으면 성공이지만 is(not())
은 반대로 같지 않아야 성공한다.
is()
는 타입만 일치하면 어떤 값이든 검증할 수 있다.sameInstance()
: 실제로 같은 오브젝트인지를 비교한다.
JUnitTest
는 equals()
를 오버라이드하지 않았으므로 Object
클래스에 정의된 대로 주소 값을 갖는 동일한 오브젝트인지를 비교하겠지만, 테스트의 의도를 명확하게 드러내기 위해 sameInstance()
라는 동일성 비교 매처를 명시적으로 사용하는 것이 좋다. 테스트 메서드가 실행될 때마다 스태틱 변수인 testObject
에 저장해둔 오브젝트와 다른 새로운 테스트 오브젝트가 만들어졌음을 확인할 수 있다. 하지만 이 방식은 직전 테스트에서 만들어진 테스트 오브젝트와만 비교한다. 그래서 세 개의 테스트 오브젝트 중 어떤 것도 중복이 되지 않는다는 것을 확인하도록 검증 방법을 바꾼다.
hasItem()
매처를 사용한다. public class JUnitTest {
static Set<JUnitTest> testObjects = new HashSet<>();
@Test
void test1() {
assertThat(testObjects, not(hasItem(this)));
testObjects.add(this);
}
@Test
void test2() {
assertThat(testObjects, not(hasItem(this)));
testObjects.add(this);
}
@Test
void test3() {
assertThat(testObjects, not(hasItem(this)));
testObjects.add(this);
}
}
이 학습 테스트를 이용하면 JUnit이 매번 새로운 테스트 오브젝트를 만든다는 사실을 분명히 확인할 수 있다. 이 학습 테스트를 통해 JUnit의 특성을 분명히 이해할 수 있게 됐고, 또 테스트를 만드는 방법에 대한 공부도 했다.
이번엔 스프링 테스트 컨텍스트 프레임워크에 대한 학습 테스트를 만들어보자. JUnit과 반대로 스프링의 테스트용 애플리케이션 컨텍스트는 테스트 개수에 상관없이 한 개만 만들어진다. 또 이렇게 만들어진 컨텍스트는 모든 테스트에서 공유된다고 했다. 이걸 검증하는 학습 테스트를 만들어보자.
1. 테스트에서 사용할 설정파일을 하나 추가한다.
applicationContext.xml
이 있지만 학습 테스트는 가능하면 독립적으로 만드는 것이 좋다.<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">
</beans>
2. JUnitTest
에 @ExtendWith
와 @ContextConfiguration
애노테이션을 추가하고, 방금 만든 설정파일을 사용하는 테스트 컨텍스트를 적용한다. 그리고 @Autowired
로 주입된 context
변수가 같은 오브젝트인지 확인하는 코드를 추가한다.
context
변수에 주입됐는지 확인해야 한다. context
를 저장해둘 스태틱 변수인 contextObject
가 null
인지 확인한다. null
이라면 첫 번째 테스트일 테니까 일단 통과하고 contextObject
에 현재 context
를 저장해둔다.contextObject
가 null
이 아닐 테니 현재의 context
와 같은지 비교할 수 있다. @ExtendWith(SpringExtension.class)
@ContextConfiguration
public class JUnitTest {
@Autowired
ApplicationContext context;
static Set<JUnitTest> testObjects = new HashSet<>();
static ApplicationContext contextObject = null;
@Test
void test1() {
assertThat(testObjects, not(hasItem(this)));
testObjects.add(this);
assertThat(contextObject == null || contextObject == this.context, is(true));
contextObject = this.context;
}
@Test
void test2() {
assertThat(testObjects, not(hasItem(this)));
testObjects.add(this);
assertTrue(contextObject == null || contextObject == this.context);
contextObject = this.context;
}
@Test
void test3() {
assertThat(testObjects, not(hasItem(this)));
testObjects.add(this);
assertThat(contextObject, either(is(nullValue())).or(is(this.context)));
contextObject = this.context;
}
}
검증 로직을 코드로 만드는 방법
assertThat()
를 사용한다.
is()
매처를 써서 true와 비교하면 된다. assertTrue()
를 assertThat()
대신 사용한다.
assertTrue()
: 조건문을 받아서 그 결과가 true
인지 false
인지 확인하는 검증용 메서드다.이번에도 assertThat()
을 사용한다.
true
와 비교하는 대신 매처의 조합을 이용한다.either()
: or()
와 함께 두 개의 매처의 결과를 OR 조건으로 비교해준다. nullValue()
: 오브젝트가 null
인지 확인해주는 매처다. 학습 테스트를 만들어보면서 세 가지 방법 중 사용하기 편리한 것을 선택해서 사용하면 된다. 학습 테스트는 이렇게 같은 기능이지만 다른 방법으로 사용하는 코드를 만들어서 비교하는 데도 유용하다.
📌 지금까지 설명한 내용 중 학습 테스트로 만들어볼 수 있는 것
- 스프링이 싱글톤 방식으로 빈의 오브젝트를 만든다는 것을 검증해보는 테스트
- 테스트 컨텍스트를 이용한 테스트에서
@Autowired
로 가져온 빈 오브젝트가 애플리케이션 컨텍스트에서 직접getBean()
으로 가져오는 것과 동일한지 검증해보는 테스트- XML에서 스트링 타입의 프로퍼티 값을 설정한 것이 정말 빈에 잘 주입되는지 검증해보는 테스트
ApplicationContext
구현 클래스의 사용 방법,getBean()
의 사용방법을 연습해볼 수 있다.
버그 테스트(bug test)란 코드에 오류가 있을 때 그 오류를 가장 잘 드러내줄 수 있는 테스트를 말한다. QA팀의 테스트 중에 기능 오류가 발견됐다고 하거나 사용자가 버그가 있다고 알려온 경우, 이때 무턱대고 코드를 수정하기보단 먼저 버그 테스트를 만들어보는 편이 유용하다.
테스트의 완성도를 높여준다.
기존 테스트에서는 미처 검증하지 못했던 부분이 있기 때문에 오류가 발생한 것이다. 이에 대해 테스트를 만들면 불충분했던 테스트를 보완해준다. 또, 이후에 비슷한 문제가 다시 등장하더라도 이전에 만들었던 버그 테스트 덕분에 쉽게 추적이 가능해진다.
버그의 내용을 명확하게 분석하게 해준다.
버그가 있을 때 그것을 테스트로 만들어서 실패하게 하려면 어떤 이유 때문에 문제가 생겼는지 명확히 알아야 한다. 또한 그 과정에서 그 버그로 인해 발생할 수 있는 다른 오류를 함께 발견할 수도 있다. 예를 들어 예외적인 상황이나 입력 값 때문에 발생하는 오류였다면, 테스트 코드를 만들면서 오류를 발생시키는 값의 범위가 어떤 것인지 분석해볼 기회가 주어진다. 테스트의 중요한 기법 중의 하나인 동등분할이나 경계값 분석을 적용해볼 수도 있다.
👉 동등분할(equivalence partitioning)
같은 결과를 내는 값의 범위를 구분해서 각 대표 값으로 테스트를 하는 방법을 말한다. 어떤 작업의 결과의 종류가 true, false 또는 예외발생 세 가지라면 각 결과를 내는 입력 값이나 상황의 조합을 만들어 모든 경우에 대한 테스트를 해보는 것이 좋다.👉 경계값 분석(boundary value analysis)
에러는 동등분할 범위의 경계에서 주로 많이 발생한다는 특징을 이용해서 경계의 근처에 있는 값을 이용해 테스트하는 방법이다. 보통 숫자의 입력 값인 경우 0이나 그 주변 값 또는 정수의 최대값, 최소값 등으로 테스트해보면 도움이 될 때가 많다.
기술적인 문제를 해결하는 데 도움이 된다.
때로는 버그가 있다는 건 알겠지만 그 원인이 무엇인지 정확하게 파악하기 힘들 때가 있다. 아무리 코드와 설정 등을 살펴봐도 별다른 문제가 없는 것 같이 느껴지거나 또는 기술적으로 다루기 힘든 버그를 발견하는 경우도 있다. 이럴 땐 동일한 문제가 발생하는 가장 단순한 코드와 그에 대한 버그 테스트를 만들어보면 도움이 된다.
2장에서는 다음과 같이 테스트의 필요성과 작성 방법을 살펴봤다.
main()
테스트 대신 JUnit 프레임워크를 이용한 테스트 작성이 편리하다. @BeforeEach
, @AfterEach
를 사용해서 테스트 메서드들의 공통 준비 작업과 정리 작업을 처리할 수 있다. @Autowired
를 사용하면 컨텍스트의 빈을 테스트 오브젝트에 DI 할 수 있다. 스프링을 사용하는 개발자라면 자신이 만든 코드를 테스트로 검증하는 방법을 알고 있어야 하며, 테스트를 개발에 적극적으로 활용할 수 있어야 한다.