과거에 테스트는 모두 사람이 하는 것이었고, 한 번의 테스트는 상당한 노동력을 필요로 했다.
하지만 로직이 대부분 쿼리에 있는 mybatis에서는 테스트하기가 상당히 까다로워 시간이 지나면서 JPA를 하게되고쿼리가 아닌 자바 코드에 로직이 많이 담기게 되었다.
유지보수의 극적인 향상이 일어났다.
Java 프로그래밍 언어를 위한 다양한 유닛 테스트 프레임워크.
JUnit 5의 주요 기능
1. 테스트 생명주기 확장(Extension Model)
2. 다양한 테스트 용어
@Test, @BeforeEach, @AfterEach와 같은 애노테이션 대신에 JUnit 5에서는 위의 세 가지 이외에도 @BeforeAll, @AfterAll, @DisplayName, @Nested등 다양한 애노테이션을 제공
3. 조건 기반 실행(Conditional Test Execution)
@EnableOnOs, @EnabledIf, @DisabledIf 등의 애노테이션을 사용해서 조건에 따라 테스트를 실행하거나 스킵할 수 있다.
4. 동적 테스트(Dynamic Tests)
@TestFactory를 사용해서 런타임에 동적으로 테스트를 생성할 수 있다. 이는 반복적인 테스트 케이스 생성이나 매개변수화된 테스트를 구현하는 데 유용하다
5. 파라미터화 테스트
여러 다른 입력값을 사용해 반복적으로 테스트 가능
6. 확장 모델
테스트 실행을 확장하고 커스터마이징 가능
7. 자동 리소스 관리
테스트 실행중에 임시 디렉토리나 파일을 자동으로 생성 및 정리 가능
spring boot start에 Junit5가 기본적으로 포함되어있다.
Junit이란 자바의 유닛테스트를 위한 라이브러리!!
Junit안에는 @SpringBootTest가 있다.
@SpringBootTest를 사용하면 빈을 사용할 때에 @Autowired만 써주면 된다.
Java를 위한 인기 있는 모킹(Mock) 프레임워크로, 객체 지향 프로그래밍에서 단위 테스트를 작성할 때 사용된다. 외부 의존성을 모킹하여 테스트를 더 효과적으로 수행할 수 있도록 도와준다.
@ExtendWith(MockitoExtension.class)
외부 기능인 MockitoExtension을 사용하겠다.
@Mock
private DeveloperRepository developerRepository;
@InjectMocks
private DMakerService dMakerService;
@Mock이 developerRepository를 @InjectMocks가 붙은 dMakerService가 생성될 때 자동으로 생성해준다
@WebMVCTest(): 컨트롤러와 관련된 빈들만 띄어준다. @Mock과 유사하다
그럼 또 한번 위에서 말한 내용을 정리해서 써보도록 하겠다.
Junit 테스트에는 대표적으로 @WebMvcTest와 @SpringBootTest를 대표적으로 사용하는데 두 가지의 어노테이션에는 차이가 존재한다.
Mock은 위에서 말했듯이 실제 객체를 만들어서 테스트하기가 어려운 경우에, 가짜 객체를 만들어서 테스트하는 기술이다.
MVC에 관련된 Mock 가짜 객체를 말한다.
웹 어플리케이션을 서버에 배포하지 않고, 테스트용 MVC 환경을 만들어서 요청 및 전송, 응답 기능을 제공해주는 객체이다.
대부분의 어플리케이션 기능을 테스트하기 위해서는 MockMVC 객체를 만들어서 테스트하게 되는데, MockMVC를 @Autowired로 주입받아서 사용할 수 있다.
이런식으로 MockMvc를 @Autowired로 주입 받을 때 두 어노테이션의 차이가 존재하며 빈의 등록범위에도 차이가 존재한다.
@SpringBootTest
class SpringBootTest {
@Autowired
MockMvc mockMvc; // 주입 X
}
@SpringBootTest만 선언하고 MockMvc를 @Autowired로 주입받으려고 하면, 주입이 되지 않아 오류가 발생한다. @SpringBootTest는 MockMvc를 빈으로 등록시키지 않기 때문에 @AutoConfigureMockMvc 어노테이션을 사용해야 한다.
@SpringBootTest
@AutoConfigureMockMvc
class SpringBootTest {
@Autowired
MockMvc mockMvc; // 주입 O
}
@WebMvcTest
class SpringBootTest {
@Autowired
MockMvc mockMvc; // 주입 O
}
@WebMvcTest는 MockMvc를 빈으로 등록하기 때문에 다른 어노테이션이 필요 없다.
@SpringBootTest
class SpringBootTest {
@Autowired
MockMvc mockMvc; // 주입 O
@Autowired
UserController userController; // 주입 O
@Autowired
UserRepository userRepository; // 주입 O
@Autowired
UserService userService; // 주입 O
}
@SpringBootTest에서는 프로젝트의 컨트롤러, 리포지토리, 서비스가 @Autowired로 다 주입된다.
@WebMvcTest
class SpringBootTest {
@Autowired
MockMvc mockMvc; // 주입 O
@Autowired
UserController userController; // 주입 O
@Autowired
UserRepository userRepository; // 주입 X
@Autowired
UserService userService; // 주입 X
}
@WebMvcTest에서는 Web Layer관련 빈들만 등록하기 때문에 컨트롤러는 주입이 정상적으로 되지만, @Component로 등록된 리포지토리와 서비스는 주입이 되지 않는다. 따라서 @WebMvcTest에서 리포지토리와 서비스를 사용하기 위해서는 @MockBean을 사용해 리포지토리와 서비스를 Mock객체에 빈으로 등록해줘야 한다.
@WebMvcTest
class SpringBootTest {
@Autowired
MockMvc mockMvc; // 주입 O
@Autowired
UserController userController; // 주입 O
@MockBean
UserRepository userRepository; // 주입 O
@MockBean
UserService userService; // 주입 O
}
위와 같은 이유로, 단위 테스트와 같은 기능 테스트가 아닌 전체적인 프로그램 작동이 제대로 이루어 지는지 검증하는 통합 테스트 시에 많이 사용한다!!!
위와 같은 이유로 컨트롤러 테스트, 단위 테스트 시에 많이 이용한다
테스트 코드를 작성하는 이유는
1. 문서화 역할
2. 코드에 결함을 발견하기 위함
3. 리팩토링 시 안정성 확보
4. 테스트 하기 쉬운 코드를 작성하다 보면 더 낮은 결합도를 설계를 얻을 수 있음
강의에서 만들 시나리오는 비밀번호 유효성 검증기이다.
위의 @ParameterizedTest는 따로 공부를 더 해야 할것 같다. 공부한 내용은 추가로 아래에 수정하도록 하겠다.
package org.example;
public class PasswordValidator {
public static void validate(String password) {
int length = password.length();
if(length <8 || length >12){
throw new IllegalArgumentException("비밀번호는 최소 8자 이상 12자 이하");
}
}
}
--
public class PasswordValidatorTest {
@DisplayName("비밀번호가 최소 8자 이상, 12자 이하면 예외 발생 X")
@Test
void validatePasswordTest() {
assertThatCode(() ->PasswordValidator.validate("serverwizard"))
.doesNotThrowAnyException();
}
@DisplayName("비밀번호가 8자 미만 또는 12자 초과하는 경우 IllegalArgumentException 예외가 발생한다")
@ParameterizedTest
@ValueSource(strings = {"aabbcce","aaaaaaaaaaaaa"})
void validatePasswordTest2(String password) {
assertThatCode(() -> PasswordValidator.validate(password))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("비밀번호는 최소 8자 이상 12자 이하");
}
}
참고로 테스트코드와 실행코드는 동일한 패키지 형식을 가져야 효율적이라고 한다.
여기서 객체 지향에 대해서 정확히 알고 가야 할 것 같다.
답은 없지만 자신만의 생각을 정확히 말할 수 있어야 한다.
그러면 테스트 코드와 객체지향의 개념을 실습으로 깨닫기 위해서 사칙 연산 계산기를 만들어보았다.
public class PositiveNumber {
private static final String ZERO_OR_NEGATIVE_NUMBER_EXCEPTION_MESSAGE = "0 또는 음수를 전달할 수 없습니다.";
//위의 코드에서 0이나 음수를 썼을 때의 오류메시지를 변수에 저장한다.
private final int value;
public PositiveNumber(int value) {
validate(value);
this.value = value;
}
private void validate(int value) {
if (isNegativeNumber(value)) {
throw new IllegalArgumentException(ZERO_OR_NEGATIVE_NUMBER_EXCEPTION_MESSAGE);
}
}
// validate 메서드를 만든다. value값을 매개변수로 넣으면 isNegativeNumber(value)로 <=0인지 확인한다. true이면 IllegalArgumentException을 발생시킨다.
private boolean isNegativeNumber(int number) {
return number <= 0;
}
public int toInt() {
return value;
}
}
public interface ArithmeticOperator {
boolean supports(String operator);
int calculate(final PositiveNumber operand1, final PositiveNumber operand2);
}
public class AdditionOperator implements ArithmeticOperator {
@Override
public boolean supports(String operator) {
return "+".equals(operator);
}
// operator 가 +이면 true
@Override
public int calculate(PositiveNumber operand1, PositiveNumber operand2) {
return operand1.toInt() + operand2.toInt();
}
//operand 1,2가 0> 이면 + 실행 <=0 이면 예외 처리
}
public class DivisionOperator implements ArithmeticOperator{
@Override
public boolean supports(String operator) {
return "/".equals(operator);
}
// operator 가 / 이면 true
@Override
public int calculate(PositiveNumber operand1, PositiveNumber operand2) {
return operand1.toInt() / operand2.toInt();
}
//operand 1,2가 0> 이면 / 실행 <=0 이면 예외 처리
}
public enum ArithmeticOperator {
ADDITION("+") {
@Override
public int calculate(final int operand1, final int operand2) {
return operand1 + operand2;
}
},
SUBTRACTION("-") {
@Override
public int calculate(final int operand1, final int operand2) {
return operand1 - operand2;
}
},
MULTIPLICATION("*") {
@Override
public int calculate(final int operand1, final int operand2) {
return operand1 * operand2;
}
}, DIVISION("/") {
@Override
public int calculate(final int operand1, final int operand2) {
if (operand2 == 0) {
throw new IllegalArgumentException("0으로 나눌 수 없습니다.");
}
return operand1 / operand2;
}
};
private final String operator;
ArithmeticOperator(String operator) {
this.operator = operator;
}
public abstract int calculate(final int operand1, final int operand2);
public static int calculate(final int operand1, final String operator, final int operand2) {
ArithmeticOperator selectedArithmeticOperator = Arrays.stream(ArithmeticOperator.values())
.filter(v -> v.operator.equals(operator))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("올바른 사칙연산이 아닙니다."));
return selectedArithmeticOperator.calculate(operand1, operand2);
}
}
위의 코드가 이번 강의에 주요 코드이다. 그래서 난이도가 좀 있다고 생각하고 나는 위에 코드에서 enum 과 stream에 대해서 잘 알지 못한다.
그래서 두 용어에 대해서 간단하게 알아보겠다.
enum의 뜻은 열거형으로 열거 가능한 상수들의 집합을 정의하는 데이터 형식.
주로 프로그래밍에서 사용되며, 특정한 값들의 집합을 정의하고 그 값들을 변수로 사용하게 해준다고 한다.
한 마디로 Enum을 사용하는 것은 특정한 선택지를 미리 정의하고, 그 중 하나를 선택하도록 강제하는 것이다.
예를 들어 가게에서는 초코, 바닐라, 딸기 세 가지 맛의 아이스크림만 판매한다. 그럴 경우
// 아이스크림 맛을 정의하는 Enum
public enum IceCreamFlavor {
CHOCOLATE,
VANILLA,
STRAWBERRY
}
// 아이스크림 가게의 고객 클래스
public class Customer {
public static void main(String[] args) {
// 고객이 초코 맛을 선택함
IceCreamFlavor choice = IceCreamFlavor.CHOCOLATE;
switch (choice) {
case CHOCOLATE:
System.out.println("초코 아이스크림을 선택하셨습니다.");
break;
case VANILLA:
System.out.println("바닐라 아이스크림을 선택하셨습니다.");
break;
case STRAWBERRY:
System.out.println("딸기 아이스크림을 선택하셨습니다.");
break;
}
}
}
위와 같이 Enum을 사용할 수 있다. 이를 참고해 보면 계산기 코드에서 Enum함수는 Enum에 따라ㅏ 다른 동작을 구현해야 하기 때문에 calculate메서드를 만든 후에 각 기호마다 다른 동작을 할 수 있도록 오버라이드하여 개별 Enum 상수에 대해 구체적인 동작을 정의한 것이다.
또한 Stream 부분은 ArithmeeticOperator.values()를 사용해서 해당 Enum의 모든 상수를 배열로 반환한 후에 filter를 통해서 배열에 있는 Enum의 상수 'v'중 operator 필드와 같은 v만 남도록 필터링을 하고 .findFirst()로 조건을 만족하는 첫 번째 요소를 찾는다. 만약 필터가 되는 기호가 없다면, IllegalArgumentException을 발생시키는 원리이다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class CalculatorTest {
@DisplayName("덧셈 연산을 수행한다")
@ParameterizedTest
@MethodSource("formulaAndResult")
void calculateTest(int operand1, String operator, int operand2, int result) {
int calculateResult = Calculator.calculate(operand1,operator,operand2);
assertThat(result).isEqualTo(result);
}
private Stream<Arguments> formulaAndResult() {
return Stream.of(
arguments(1,"+",2,3),
arguments(1,"-",2,-1),
arguments(4,"*",2,8),
arguments(4,"/",2,2)
);
}
}
위와 같은 테스트 코드를 통해서 코드를 계속 리팩토링하면 가장 효율적인 코드를 찾을 수 있게 된다.
다음 예시는 학점계산기를 구현해 보았다.
요구사항은
- 평균학점 계산 방법 = (학점수 x 교과목 평점)의 합계/수강신청 총학점 수
- MVC패턴 기반으로 구성
- 일급 컬렉션을 사용
package org.example.grade.domain;
public class Course {
public static final int MAJOR_CREDIT = 3;
public static final int GENERAL_CREDIT = 2;
private final String subject; // 과목
private final int credit; // 학점
private final String grade; // 성적
public Course(String subject, int credit, String grade) {
this.subject = subject;
this.credit = credit;
this.grade = grade;
}
public double multiplyCreditAndCourseGrade() {
return credit * getGradeToNumber();
}
public int getCredit() {
return this.credit;
}
private double getGradeToNumber() {
double gradeInt = 0;
switch (this.grade) {
case "A+":
gradeInt = 4.5;
break;
case "A":
gradeInt = 4.0;
break;
case "B+":
gradeInt = 3.5;
break;
case "B":
gradeInt = 3.0;
break;
case "C+":
gradeInt = 2.5;
break;
case "C":
gradeInt = 2.0;
break;
case "D+":
gradeInt = 1.5;
break;
case "D":
gradeInt = 1.0;
break;
case "F":
gradeInt = 0.0;
break;
}
return gradeInt;
}
}
Course 클래스의 간단한 메서드와 생성자이다.
package org.example.grade.domain;
import java.util.List;
public class Courses {
private final List<Course> courses;
public Courses(List<Course> courses) {
this.courses = courses;
}
// 학점수×교과목 평점
public double multiplyCreditAndCourseGrade() {
return courses.stream()
.mapToDouble(Course::multiplyCreditAndCourseGrade)
.sum();
}
// 총 이수한 학점
public int calculateTotalCompletedCredit() {
return courses.stream()
.mapToInt(Course::getCredit)
.sum();
}
}
stram을 이용해서 Courses 리스트안의 각 요소인 course를 multiplyCreditAndCourseGrade 메서드를 실행시키고 모두 sum을 시킨 후 double로 return한다.
import org.example.grade.domain.Course;
import org.example.grade.domain.Courses;
import org.example.grade.domain.GradeCalculator;
import org.example.grade.domain.GradeResult;
import org.example.grade.ui.ConsoleOutputUI;
import org.junit.jupiter.api.Test;
import java.util.List;
public class GradeCalculatorTest {
// 학점계산기, 코스
// 평균학점 계산 요청 ---> '학점계산기' ---> (학점수×교과목 평점)의 합계 ---> '코스'
// ---> 수강신청 총학점 수
@Test
void calculateGradeTest() {
// given
List<Course> courses = List.of(new Course("OOP", Course.MAJOR_CREDIT, "A+"),
new Course("자료구조", Course.MAJOR_CREDIT, "A+"),
new Course("중국어회화", Course.GENERAL_CREDIT, "C"));
// when
GradeCalculator gradeCalculator = new GradeCalculator(new Courses(courses));
GradeResult gradeResult = gradeCalculator.calculateGrade();
// then
ConsoleOutputUI.printGrade(gradeResult);
}
}
위의 코드 역시 테스트 코드를 사용하면 더 편하게 만들 수 있다.
@WebMvcTest(DMakerController.class)
// 내가 지정한 Controller 전용 Test
@SpringBootTest : 통합 테스트 용도로 사용된다. @SpringBootApplication을 찾아가 하위의 모든 Bean을 스캔하여 로드한다. 그 후 Test용 Application Context를 만들어 Bean을 추가하고, MockBean을 찾아 교체한다.
@ExtendWith : 메인으로 실행될 Class를 지정할 수 있으며, @SpringBootTest는 기본적으로 @ExtendWith가 추가되어 있다
@WebMcvTest(Class명.class) : ()에 작성된 클래스만 실제로 로드하여 테스트를 진행, 매개변수를 지정해주지 않으면 @Controller, @RestController, @RestControllerAdvice 등 컨트롤러와 연관된 Bean이 모두 로드된다. 스프링의 모든 Bean을 로드하는 @SpringBootTest 대신 컨트롤러 관련 코드만 테스트할 경우 사용
@Autowired about MockBean : Contoller의 API를 테스트 하는 용도인 MockMvc 객체를 주입 받는다. perform() 메소드를 활용해 컨트롤러의 동작을 확인할 수있다
@MockBean : 테스트할 클래스에서 주입 받고있는 객체에 대해 가짜 객체를 생성해주는 어노테이션, 해당 객체는 실제 행위를 하지 않으며 given() 메소드를 활용해서 가짜 객체의 동작에 대해 정의하여 사용할 수 있다.
@AutoConfigureMockMvc : spring.test.mockmvc의 설정을 로드하면서 MockMvc의 의존성을 자동으로 주입하며 MockMVC 클래스는 REST API 테스트를 할 수 있는 클래스이다.
@Import : 필요한 Class들을 Configuration으로 만들어 사용할 수 있다
여러 기능을 조합하여 전체 비즈니스 로직이 제대로 동작하는지 확인하는 것을 의미
프로젝트에 필요한 모든 기능에 대한 테스트를 각각 진행하는 것을 의미