class Solution {
public int solution(int n, int[][] computers) {
int answer = 0;
boolean[] visited = new boolean[computers.length];
// 노드 방문 초기화
for (boolean visit : visited) {
visit = false;
}
for(int i = 0; i < computers.length; i++) {
if(visited[i] == false) { // 해당 노드를 방문하지 않았을 경우
answer++; // 새로운 네트워크 찾았으므로 +1 증가
dfs(i, visited, computers);
}
}
return answer;
}
// node: 현재 노드, visited: 방문여부, computers: 컴퓨터간의 연결 정보를 나타내는 2차원 배열
public void dfs(int node, boolean[] visited, int[][] computers) {
visited[node] = true;
for(int i = 0; i < computers.length; i++) {
// 아직 방문하지 않는 노드 && 현재 노드와 연결된 경우
if(visited[i] == false & computers[node][i] == 1) {
dfs(i, visited, computers);
}
}
}
}
DFS에 대한 알고리즘 개념을 안 상태로 해당 문제에 접근하면,
(1) 처음에 노드 방문을 boolean 배열을 만들어서 컴퓨터의 개수(n)만큼 for문을 돌려서 초기화해주고,
(2) visited[i] 값이 false이면 깊이 우선 탐색(dfs)
메서드를 호출하고 answer++ 해준다.
(3) 전달받은 파라미터인 visited[i] 값을 true로 바꿔준다.
(4) computers[] 길이만큼 반복문들 돌면서
(5) 아래 조건을 모두 만족하면 재귀 호출을 한다.
자기 자신이 아니며 (i != j)
visited 배열 i의 위치 값이 false이며
computers 배열의 값이 1인 것
(6) 2번으로 돌아간다.
(7) answer을 리턴한다.
// 프로그래머스 - 네트워크
class Solution {
static boolean[] visited;
public int solution(int n, int[][] computers) {
int answer = 0;
visited = new boolean[computers.length];
// 노드 방문 초기화
for(boolean visit : visited) {
visit = false;
}
for(int i = 0; i < computers.length; i++) {
if(visited[i] == false) {
answer ++;
dfs(i, computers);
}
}
return answer;
}
/*
* node: 현재 노드
* visited: 방문여부
* computer: 컴퓨터간의 연결 정보를 나타내는 2차원 배열
*/
public void dfs(int node, int[][] computers) {
visited[node] = true;
for(int i = 0; i < computers.length; i++) {
if(visited[i] == false && computers[node][i] == 1) {
dfs(i, computers);
}
}
}
}
첫번째 풀이와 다른 점은 visited 배열을 static으로 선언해서 dfs를 호출할 때, visited 매개변수를 없애주었다.
해당 문제는 연결된 요소 찾기
유형과 흡사해서, 이전에 풀었던 문제와 비슷했지만, 아직은 익숙치 않아서 더 자주 풀어봐야겠다.
이 글은 Practical Testing: 실용적인 테스트 가이드 강의를 듣고 내용을 정리한 글입니다.
지난 글에 이어서 정리해보려고 합니다.
새로운 요구사항
관리자 페이지에서 주문관리탭 - 오늘 하루동안 발생한 매출 통계를 메일로 전송받는 기능을 구현한다. → 날짜와 결제금액
주문 데이터를 기반으로 총 결제가 이뤄진 금액을 알고 싶다.
주문통계에 대한 서비스 - OrderStatisticsService
(실무에서는 주문완료 시간과 별도로 결제완료 시간에 대한 필드가 있어야 하는데, 이 토이 프로젝트에서는 모의로 구현하는 것이기때문에 일단 주문완료 시간을 가지고 구현한다)
해당 요구사항에 대한 구현 코드 - Mockito로 Stubbing하기입니다.
해당 코드에서
OrderStatisticsServiceTest.java
을 참고해주시면 되겠습니다.
Stubbing
이란 테스트를 진행할 때 가짜 객체(Mock)에 대해 어떤 행동을 하도록 지정하는 작업을 말한다.
여기서 Stubbing
을 하기 위해 MailSendClient 클래스를 @MockBean
을 통해 Mockito에서 만든 Mock 객체를 주입한 다음, 원하는 행위를 정의해준다.
MockBean
은 기존에 사용되던 Bean의 껍데기만 가져오고 내부의 구현 부분은 모두 사용자에게 위임한 형태이다. 실제 빈의 동작과는 별개로 사용자(개발자)가 원하는 행동을 정의할 수 있다.
(@MockBean
은 @SpringBootTest
에서 사용되며, 테스트에서 사용할 Mock 객체를 주입하는 데에 쓰인다)
이렇게 @MockBean
을 이용하여 Stubbing 하는 행위는 given
절에서 작성한다.
when(mailSendClient.sendEmail(any(String.class), any(String.class), any(String.class), any(String.class))).thenReturn(true);
를 예로 들면,
when
메서드는 특정 메서드 호출이 발생할 때 어떤 값으로 리턴해야 하는지 정의하는데, 여기서는 mailSendClient.sendEmail
메서드가 아무 문자열(any(String.class)) 인자로 호출될 때,
모의 객체(Mock)인 mailSendClient
는 항상 true를 반환한다는 의미이다.
이처럼 테스트 환경에서는 실제로 이메일을 보내지 않고도, 메일 전송 메서드의 성공적인 호출을 시뮬레이션하기 위해 Mockito로 Stubbing 하는 방식
을 사용한다.
[참고] 메일 전송과 같은 로직에서는
Transactional
어노테테이션을 붙일 필요가 없다.
OrderStatisticsService
에는 DB 조회를 하는 findOrdersBy
와 같은 로직이 존재하는데 왜 트랜잭션이 필요가 없을까?
DB 조회를 할 때 커넥션을 가지고 메일이 전송 완료될 때까지 커넥션을 유지하고 있을 것이다. 메일 전송 등과 같은 오랜 시간이 걸리는 작업에서는 트랜잭션을 걸지 않는 것이 좋다.
Test Double에 대한 정의와 종류에 대해 자세히 알고 싶다면, 이 글을 참고하자.
Dummy
: 아무것도 하지 않는 깡통 객체
Fake
: 단순한 형태로 동일한 기능은 수행하나, 프로덕션에서 쓰기에는 부족한 객체(FakeRepository)
Stub
: 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체. 그 외에는 응답하지 않는다.
Spy
: Stub이면서 호출된 내용을 기록하여 보여줄 수 있는 객체. 일부는 실제 객체처럼 동작시키고 일부만 Stubbing만 할 수 있다. → 실제 객체와 유사하게 동작
Mock
: 행위
에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체
공통점: 둘다 가짜 객체, 요청한 것에 대해 미리 준비한 결과를 제공한다.
차이점: 검증하는 목적이 다르다. (There is a difference in that the stub uses state verification while the mock uses behavior verification. - Martin Fowler -)
Stub
: 상태 검증(State Verification)
Mock
: 행위 검증(Behavior Verification)
아래 코드는 MailService
의 sendMail 메서드에 대한 테스트 코드이다.
// MailService 클래스
// 클라이언트(MailSendClient)를 통해 메일을 보낸 후, 이를 성공적으로 전송했을 때 해당 이력을 mailSendHistoryRepository 을 통해 기록하는 역할을 수행
@RequiredArgsConstructor
@Service
public class MailService {
private final MailSendClient mailSendClient;
private final MailSendHistoryRepository mailSendHistoryRepository;
public boolean sendMail(String fromEmail, String toEmail, String subject, String content) {
boolean result = mailSendClient.sendEmail(fromEmail, toEmail, subject, content);
if(result) {
mailSendHistoryRepository.save(MailSendHistory.builder()
.fromEmail(fromEmail)
.toEmail(toEmail)
.subject(subject)
.content(content)
.build()
);
return true;
}
return false;
}
}
// MailServiceTest
class MailServiceTest {
@DisplayName("메일 전송 테스트")
@Test
void sendMail() {
// given
MailSendClient mailSendClient = Mockito.mock(MailSendClient.class);
MailSendHistoryRepository mailSendHistoryRepository = Mockito.mock(MailSendHistoryRepository.class);
MailService mailService = new MailService(mailSendClient, mailSendHistoryRepository);
// Stubbing - Mock 객체에 원하는 행위를 정의하는 것
when(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
// when
boolean result = mailService.sendMail("", "", "", "");
// then
Assertions.assertThat(result).isTrue();
}
}
자세히 보면, MailService
의 sendMail 메서드에서 mailSendHistoryRepository.save()
에 대한 Stubbing 없이 sendMail 메서드를 테스트하면 정상적으로 통과가 되는데 왜 그런걸까?
Mockito의 mock 메서드에 가보면 아래와 같이 withSettings() 메서드를 볼 수 있다.
여기서 withSettings() 메서드에서 리턴하는 부분에서 RETURNS_DEFAULTS
에 가보면
Integer인 경우 zero을 리턴하고, null이 반환되는 값들은 null을 반환하고, Collection의 경우 empty 를 반환하도록 기본 정책이 걸려있는 걸 확인할 수 있다.
그래서 save 메서드를 호출했을 때 기본으로 null을 반환하도록 하여 테스트가 통과된 것을 알 수 있다.
이를 조금 더 명시적으로 검증하기 위해 아래와 같이 verify
메서드를 통해 작성해볼 수 있다.
// mailSendHistoryRepository.save()가 1 번 호출되었는지를 검증
Mockito.verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
위의 MailServiceTest
을 다음과 같이 리팩터링 작업을 진행한다.
MailSendClient
, MailSendHistoryRepository
클래스를 Mock
어노테이션을 이용하여 생성자를 주입한다.
그리고 MailServiceTest 위에 @ExtendWith(MockitoExtension.class)
걸어준다 -> “테스트가 시작될때, Mockito를 통해 mock 만들거야”를 알려줘야 한다. → 그리고 mock 객체를 만들어주고 mailService
에 넣어주게 된다.
@ExtendWith(MockitoExtension.class)
class MailServiceTest {
@Mock
private MailSendClient mailSendClient;
@Mock
private MailSendHistoryRepository mailSendHistoryRepository;
@DisplayName("메일 전송 테스트")
@Test
void sendMail() {
// given
MailService mailService = new MailService(mailSendClient, mailSendHistoryRepository);
// Stubbing - Mock 객체에 원하는 행위를 정의하는 것
when(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
// when
boolean result = mailService.sendMail("", "", "", "");
// then
Assertions.assertThat(result).isTrue();
// mailSendHistoryRepository.save()가 1 번 호출되었는지를 검증
Mockito.verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}
}
여기서 MailService
도 @InjectMocks
어노테이션으로 만들어줄 수 있다.
MailService
에 생성자를 보고 Mockito의 mock으로 선언된 MailSendClient
, MailSendHistoryRepository
애들을 inject 해준다.
즉, @InjectMocks
어노테이션은 DI와 똑같은 일을 하게 된다.
MailService
클래스 내에서 사용하는 Mock 객체들을 자동으로 주입해주는 역할이다. => MailService mailService = new MailService(mailSendClient, mailSendHistoryRepository);
이는 Mockito가 관리하는 Mock 객체들을 테스트 대상 클래스에 주입하여 테스트를 수행할 수 있도록 도와주는 기능이다.
InjectMocks 어노테이션 사용하면 아래와 같이 된다.
@ExtendWith(MockitoExtension.class)
class MailServiceTest {
@Mock
private MailSendClient mailSendClient;
@Mock
private MailSendHistoryRepository mailSendHistoryRepository;
@InjectMocks
private MailService mailService;
@DisplayName("메일 전송 테스트")
@Test
void sendMail() {
// given
// Stubbing - Mock 객체에 원하는 행위를 정의하는 것
when(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
// when
boolean result = mailService.sendMail("", "", "", "");
// then
Assertions.assertThat(result).isTrue();
// mailSendHistoryRepository.save()가 1 번 호출되었는지를 검증
Mockito.verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}
}
@Spy
어노테이션은 객체의 일부 메소드만을 Mock으로 대체할 수 있기 때문에, 나머지 메소드는 실제 객체의 동작을 그대로 따르게 된다.
이는 특히 특정 메소드의 동작을 유지하면서 일부 동작을 변경하거나 감시할 때 유용하다. -> 일부만 Stubbing만 하여 테스트를 할 수 있다.
예를 들어, MailSendClient
클래스에 여러 메서드 기능들이 있고, MailService
에서 a,b,c 기능을 그대로 사용하고 있다고 가정해보자.
// MailSendClient - sendMail, a, b, c 메서드가 구현되어 있음.
@Slf4j
@Component
public class MailSendClient {
public boolean sendEmail(String fromEmail, String toEmail, String subject, String content) {
log.info("메일 전송");
throw new IllegalArgumentException("메일 전송");
}
public void a() {
log.info("a");
}
public void b() {
log.info("b");
}
public void c() {
log.info("c");
}
}
// MailService - a, b, c 기능을 그대로 사용하고 있음.
@RequiredArgsConstructor
@Service
public class MailService {
private final MailSendClient mailSendClient;
private final MailSendHistoryRepository mailSendHistoryRepository;
public boolean sendMail(String fromEmail, String toEmail, String subject, String content) {
boolean result = mailSendClient.sendEmail(fromEmail, toEmail, subject, content);
if(result) {
mailSendHistoryRepository.save(MailSendHistory.builder()
.fromEmail(fromEmail)
.toEmail(toEmail)
.subject(subject)
.content(content)
.build()
);
mailSendClient.a();
mailSendClient.b();
mailSendClient.c();
return true;
}
return false;
}
}
//
여기서 MailServiceTest
에서 MailSendClient
의 sendEmail()만 Stubbing 하고 싶고, 나머지 a,b,c는 원본 객체의 기능이 동일하게 동작하고 싶은 경우 @Spy
어노테이션을 활용한다.
@Spy
는 실제 객체를 기반으로 만들어지기 때문에, MailServiceTest
에서 아래와 같은 when 부분을 지워야(주석 처리해야) 한다. → Stubbing이 되지 않는다.
대신 doReturn으로 아래와 같이 작성해준다.
@ExtendWith(MockitoExtension.class)
class MailServiceTest {
@Spy
private MailSendClient mailSendClient;
@Mock
private MailSendHistoryRepository mailSendHistoryRepository;
@InjectMocks
private MailService mailService;
@DisplayName("메일 전송 테스트")
@Test
void sendMail() {
// given
// // Stubbing - Mock 객체에 원하는 행위를 정의하는 것
// when(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
// .thenReturn(true);
doReturn(true)
.when(mailSendClient)
.sendEmail(anyString(), anyString(), anyString(), anyString());
// when
boolean result = mailService.sendMail("", "", "", "");
// then
Assertions.assertThat(result).isTrue();
// mailSendHistoryRepository.save()가 1 번 호출되었는지를 검증
Mockito.verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}
}
sendEmail()만 원하는 Stubbing이 된거고, 나머지 a,b,c 라는 실제 객체는 그대로 동작이 되었다.
이처럼 일부는 Stubbing, 나머지 실제처럼 사용할 때 @Spy
어노테이션을 사용한다.
(사실 @Spy
를 사용하면 특정 서비스의 일부 기능만 테스트하는데, 빈도수가 그렇게 많지는 않아서 실무에서는 @Spy
보다는 @Mock
을 더 자주 사용한다)
아래 코드에서 given 절을 보면, 어색한 부분을 확인할 수 있다.
@ExtendWith(MockitoExtension.class)
class MailServiceTest {
@Mock
private MailSendClient mailSendClient;
@Mock
private MailSendHistoryRepository mailSendHistoryRepository;
@InjectMocks
private MailService mailService;
@DisplayName("메일 전송 테스트")
@Test
void sendMail() {
// given - Stubbing
Mockito.when(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
// when
boolean result = mailService.sendMail("", "", "", "");
// then
Assertions.assertThat(result).isTrue();
// mailSendHistoryRepository.save()가 1 번 호출되었는지를 검증
Mockito.verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}
}
given 절에 사용된 Mockito.when()
메서드는 Stubbing을 위해 given 영역에 사용하는 것이 맞지만 when() 이라는 문법이 가독성을 저하시키고 혼란을 야기할 수 있다.
그래서 이를 해결하기 위해 BDDMockito의 문법인 BDDMockito.given()
메서드를 사용하면, given 절과 유사해진다.
@ExtendWith(MockitoExtension.class)
class MailServiceTest {
@DisplayName("메일 전송 테스트")
@Test
void sendMail() {
// given
// BDDMockito의 문법인 BDDMockito.given을 사용하면 BDD 스타일을 지킬 수 있다.
BDDMockito.given(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
.willReturn(true);
// when
boolean result = mailService.sendMail("", "", "", "");
// then
Assertions.assertThat(result).isTrue();
// mailSendHistoryRepository.save()가 1 번 호출되었는지를 검증
Mockito.verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}
}
BDDMockito
를 가보면 Mockito를 감싸고 있다. (상속받고 있다)
모든 동작이 같은데, BDD 스타일로만 바뀐 것이다. -> 이름만 바꿨고, 기능은 동일하다.
결론은 앞으로 테스트 코드를 BDD 스타일로 작성할 때는 BDDMockito
문법을 사용하자!
Classicist - 진짜 객체로 테스트를 하자. -> 상태 검증을 통한 의존 객체의 구현보다는 실제 입/출력 값을 통해 검증하는 테스트이다.
Mockist - 모든 걸 mocking 위주(가짜 객체)로 테스트를 하자. -> 행위 검증을 통해 의존 객체의 특정 행동이 이루어졌는지를 검증하는 테스트이다.
이번 강의에서는 Controller 테스트할 때는 Service와 Repository를 Mocking하여 단위 테스트를 진행했고, Service 테스트 할 때는 Repository의 실제 객체를 사용한 통합 테스트를 진행했다.
Mockist 입장에서 바라보면 Service 테스트 할 때에도 Repository에도 실제 객체가 아닌 Mocking을 하여 단위 테스트로 신속히 테스트를 해야 한다. -> 어느 것이 더 좋은 방법일까?
이 강의를 만드신 우빈님의 생각
메일 전송같은 외부 시스템을 요청하거나 연결할 때 Mocking을 쓴다. -> 외부 시스템은 우리가 개발한 게 아니기 때문이다.
Mocking 위주로 테스트를 작성한다면, “실제 프로덕션 코드에서 런타임 시점에 일어날 일을 정확하게 Stubbing(Mocking)
했다고 단언할 수 있는가?”
테스트를 했다고 100% 재현할 수 있나? → 그런 리스크를 안고 갈 바에는 비용을 조금 더 들여서 실제 객체를 가져와서 테스트를 하는게 낫다는게 Classicist 입장이신 우빈님의 생각이다.
-> 내 생각: AWS S3나 소셜 로그인(OAuth 2.0)과 같은 외부 시스템과 연동하는 비즈니스 로직을 테스트할 경우에는 Mock을 사용하는 것이 효과적이라는 생각이 들었다.
테스트 코드를 글쓰기 관점에서 봤을 때, 하나의 테스트는 하나의 주제만을 가져야 한다.
논리구조에는 분기문(if), 반복문(while, for)이 있다.
분기문이 존재한다는 것 자체가 2가지 이상 내용이 들어가있다는 걸 반증한다.
반복문 역시 테스트 코드를 읽는 사람이 한번 더 생각해야 한다.
결론은, 케이스가 두 가지 이상 생기면 → 테스트 코드를 2개로 나눠서 코드를 작성한다. -> 논리구조는 방해 요소가 될 수 있기 때문에 되도록 지양하자.
테스트 환경에서 제어할 수 없는 것들은 완벽하게 제어할 수 있도록 한다.
LocalDateTime.now()
와 같은 제어할 수 없는 코드의 경우 -> 현재 시간을 분리해서 상위 레벨로 올리고, 테스트할 때는 원하는 시간을 주입해서 상황을 재연한다.
현재 시간이라는 데이터를 기준으로 테스트하는 것 보다, 고정된 날짜 또는 시간 등을 가지고 테스트하는 것이 좋다.
// CafeKiosk
@Getter
public class CafeKiosk {
public Order createOrder() {
LocalDateTime currentDateTime = LocalDateTime.now();
LocalTime currentTime = currentDateTime.toLocalTime();
if(currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) {
throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
return new Order(currentDateTime, beverages);
}
public Order createOrder(LocalDateTime currentDateTime) {
LocalTime currentTime = currentDateTime.toLocalTime();
if(currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) {
throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
return new Order(currentDateTime, beverages);
}
}
// CafeKioskTest
class CafeKioskTest {
@Test
void createOrder() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano);
Order order = cafeKiosk.createOrder();
assertThat(order.getBeverages()).hasSize(1);
assertThat(order.getBeverages().get(0).getName()).isEqualTo("아메리카노");
}
@Test
void createOrderWithCurrentTime() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano);
Order order = cafeKiosk.createOrder(LocalDateTime.of(2023, 11, 30, 10, 0));
assertThat(order.getBeverages()).hasSize(1);
assertThat(order.getBeverages().get(0).getName()).isEqualTo("아메리카노");
}
@Test
void createOrderOutsideOpenTime() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano);
assertThatThrownBy(() -> cafeKiosk.createOrder(LocalDateTime.of(2023, 11, 30, 9, 59)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
}
createOrder()
는 현재 시간을 기준으로 성공할 수도, 실패할 수도 있다.
반면에, createOrderWithCurrentTime()
, createOrderOutsideOpenTime()
는 임의의 시간을 정해서 원하는 상황을 완벽하게 연출할 수 있게 되었다.
외부 시스템일 경우, Mocking 처리하고 테스트를 구성한다.
```java // OrderServiceTest class OrderServiceTest { @DisplayName(“재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.”) @Test void createOrderWithNoStock() { LocalDateTime registeredDateTime = LocalDateTime.now(); // given Product product1 = createProduct(BOTTLE, “001”, 1000); Product product2 = createProduct(BAKERY, “002”, 3000); Product product3 = createProduct(HANDMADE, “003”, 5000); productRepository.saveAll(List.of(product1, product2, product3));
Stock stock1 = Stock.create("001", 2);
Stock stock2 = Stock.create("002", 2);
stock1.deductQuantity(1); // todo
stockRepository.saveAll(List.of(stock1, stock2));
```java // [백준] 16173. 점프왕 쩰리 (Small) - 23.12.11 import java.util.; import java.io.;
class Jump_king_jelly { static final int MAX = 3 + 100 + 10; static int map[][]; static boolean visited[][]; static int N; static int dirY[] = {1, 0}; static int dirX[] = {0, 1};
public static void dfs(int y, int x) { visited[y][x] = true;
if(y == N && x == N)
return;
for(int i = 0; i < 2; i++) {
int newY = y + dirY[i] * map[y][x];
int newX = x + dirX[i] * map[y][x];
if(visited[newY][newX] == false) {
dfs(newY, newX);
}
} }
public static void main(String[] args) throws IOException { // 0. 입력 및 초기화 BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
N = Integer.parseInt(br.readLine());
map = new int[MAX][MAX];
visited = new boolean[MAX][MAX];
// 1. map에 정보 반영
for(int i = 1; i <= N; i++) {
StringTokenizer st = new StringTokenizer(br.readLine());
for(int j = 1; j <= N; j++) {
map[i][j] = Integer.parseInt(st.nextToken());
}
}
이 글은 Practical Testing: 실용적인 테스트 가이드 강의를 듣고 내용을 정리한 글입니다.
테스트 코드의 필요성을 이해하고 숙지한다.
좋은 테스트 코드가 무엇이고 깔끔하고 명확한 테스트 코드를 작성하는 방법을 학습하고 정리한다.
Spring 및 JPA 기반의 API를 설계하고 개발하는 과정에서 실무 수준의 테스트 코드를 작성하고 이를 정리한다.
테스트
는 기본적으로 귀찮은 작업이다. 실무에서는 짧은 시간안에 기능 구현만 만들기도 벅찬데, 테스트 코드를 작성하기가 쉽지 않다.
테스트의 필요성을 명확히 이해하지 못하면, 이 작업을 소홀히하고 무시하는 경향이 생길 수 있다.
테스트 코드가 없는 환경에서는 새로운 기능을 추가하거나 코드를 수정할 때 기존 코드가 여전히 정상 작동하는지 확인하기 위해 사람이 직접 수동으로 테스트해야 한다.
프로덕션 코드는 시간이 지날수록 점점 더 확장하게 되는데, 그때마다 테스트 인력을 무한정 늘릴 수 있을 까에 대한 고민이 있을 수 있다.
그리고 사람이 수동으로 테스트를 하다 보면 누락되는 케이스가 발생할 수도 있고, 그게 곧 치명적인 결함이 돼서 실제 상용 소프트웨어에 큰 문제가 발생할 수도 있다.
소프트웨어가 커지는 속도를 따라잡지 못하게 되고, 기능들도 서로 겹치면서 기존에 테스트 했던 영역을 또 테스트하게 되면서 커버할 수 없는 영역이 발생하게 된다.
또한 프로덕션 코드가 확장됨에 따라 이 프로젝트를 오래 했던 사람들 또는 개발(or 테스트)을 오래 했던 사람들의 경험과 감에 의존할 수 밖에 없게 된다.
사람이 테스트를 하다 보니까 시간이 오래 걸려서 피드백이 늦어지게 되고, 테스트 도중 버그가 생기면 다시 수정 개발을 하면서 이런 사이클이 되게 느리게 돌아가게 된다. 이로 인해 유지보수가 어려워지고, 이는 결국 소프트웨어의 신뢰도를 낮추는 일이 된다.
그래서 우리는 테스트 코드를 통해서 내가 개발한 기능에 대해서 내가 의도한 대로 동작하는지 빠른 피드백을 받을 수 있어야 하고, 기계가 검증할 수 있도록 자동화를 해서 내가 만든 소프트웨어에 대한 안정감과 신뢰감을 얻을 수 있어야 한다.
빠뜨리지 않고 테스트 코드를 잘 추가했다면, 커지는 소프트웨어를 프로덕션 코드를 테스트 코드가 계속 커버할 수 있게 된다.
그런데 테스트 코드가 엉망으로 작성되어 있다면,
프로덕션 코드의 안정성을 제공하기 힘들며, 유지보수하기 어려운 상황이 발생하여, 이는 곧 테스트의 검증이 잘못될 가능성이 높아진다.
그래서 올바른 테스트 코드를 작성해야 한다.
올바른 테스트 코드를 통해 자동화가 되어 빠른 시간안에 버그를 발견할 수 있고, 수동 테스트에 드는 비용을 크게 절약할 수 있다.
그리고 소프트웨어의 빠른 변화를 지원할 수 있게 해준다.
또한 내가 고민했던 것들을 코드로 녹여내면, 팀내 공유 지식이 되면서 팀원들의 집단 지성을 팀 차원의 이익으로 승격시켜주는 이점이 있다.
이는 ‘가까이 보면 느리지만, 멀리 보면 가장 빠르다’ 라고 표현될 수 있다.
테스트
는 확실한 귀찮은 작업이지만, 이를 극복하고 왜 해야 하는지 명확히 이해하고, 실무에서 테스트 작성 시 귀찮음을 인정하면서도 꾸준한 노력으로 해내야 한다는 마음가짐을 갖춰야 한다.
프로젝트 주제인 ‘초간단 카페 키오스크 시스템’을 개발하면서 테스트 코드를 작성하는 방법을 배워보자.
해당 프로젝트에 대한 실습을 깃허브에 기록했습니다.
개발 환경은 다음과 같다.
IntelliJ Ultimate
Java 11
Spring Boot 2.7.7
Gradle & Groovy
Dependency - Spring Web, Thymeleaf, Spring Data JPA, H2 Database, Lombok, Validation
단위 테스트
란 작은 코드 단위를 독립적으로 검증하는 테스트이다. 여기서 작은 코드는 클래스 혹은 메서드를 의미한다.
단위 테스트
는 검증 속도가 빠르고 안정적인 특징을 가진다.Junit5
란 단위 테스트를 위한 테스트 프레임워크이다. (참고 - 공식문서)
AssertJ
란 테스트 코드 작성을 원활하게 돕는 테스트 라이브러리이다. 풍부한 API, 메서드 체이닝을 지원한다. (참고 - 공식문서)
요구 사항: 한 종류의 음료 여러 잔을 한 번에 담는 기능
이러한 요구 사항이 들어왔을 때, 자신에게 혹은 요구 사항을 들고온 기획자, 타직군에게 다시 질문을 해 볼 수 있어야 한다.
질문하기: 암묵적이거나 아직 드러나지 않은 요구 사항이 있는지 항상 염두하고 고민을 해봐야 한다.
해피 케이스
와 예외 케이스
, 이 두가지 케이스를 가지고 경계값 테스트를 도출 할 수 있어야 한다.
여기서 경계값 테스트
는 범위(이상, 이하, 미만, 초과), 구간, 날짜 등을 말한다.
예를 들어, 어떤 정수 값이 있고 이 정수가 3이상일 때, A라는 조건을 만족한다고 가정한다면,
3 이상에 대한 해피 케이스와 3 미만에 대한 예외 케이스를 작성해야 한다.
해당 요구 사항(한 종류의 음료 여러 잔을 한 번에 담는 기능)에 대해 해피 케이스와 예외 케이스를 작성해보면 아래와 같다.
addSeveralBeverages
메서드는 하나의 종류인 아메리카노에 2개를 담는 테스트로 해피 케이스이고, addZeroBeverages
메서드는 하나의 종류인 아메리카노에 0개를 담았을 때 예외가 발생하는 예외 케이스인 것을 확인할 수 있다.
요구사항: 가게 운영 시간(10:00 ~ 22:00) 외에는 주문을 생성할 수 없다.
영업 시간 내에 주문이 생성되려면 시간과 관련된 부분도 고려하기 때문에 테스트하기 어려울 수 있다.
주문을 생성하는 로직에 시간과 관련된 부분을 추가하게 되면, 기존 프로덕션 코드가 전부 수정되어야 하기 때문에 좋지 않다.
이를 위해 주문을 생성할 때 외부에서 시간 데이터를 받아올 수 있도록 변경한다면 테스트 시에는 원하는 시간을 통해 검증할 수 있고, 프로덕션 코드에서도 현재 시간을 인자로 주어 동작할 수 있다.
```java @Getter public class CafeKiosk {
public static final LocalTime SHOP_OPEN_TIME = LocalTime.of(10, 0);
public static final LocalTime SHOP_CLOSE_TIME = LocalTime.of(22, 0);
이 글은 자바의 정석 책에서 나온 개념과 예제를 학습하고 정리한 글입니다.
java.lang
패키지는 자바프로그래밍에서 가장 기본이 되는 클래스를 포함하고 있다.
그렇기 때문에 java.lang
패키지의 클래스들은 import문 없이도 사용할 수 있게 되어 있다.
java.lang
패키지의 여러 클래스들 중에서 자주 사용되는 Object
클래스와 관련 메서드에 대해 학습해보자.
Object
클래스는 모든 클래스의 최고 조상이기 때문에 Object 클래스의 멤버들은 모든 클래스에서 바로 사용 가능하다.
Object
클래스는 멤버변수는 없고 오직 11개의 메서드만 가지고 있다. 이 메서드들은 모든 인스턴스가 가져야 할 기본적인 것들이며, 이 중에서 중요한 몇 가지만 살펴보자.
(11개 메서드에 대해 확인하고 싶으면, “자바의 정석” p.450을 참고하자)
equals()는 매개변수로 객체의 참조변수를 받아서 비교하여 그 결과를 boolean 값으로 알려주는 역할을 한다.
아래의 코드는 Object 클래스에 정의되어 있는 equals의 실제 내용이다.
public boolean equals(Object obj){
return(this == obj);
}
두 객체의 같고 다름을 참조변수의 값으로 판단한다. 그렇기 때문에 서로 다른 두 객체를 equals
메서드로 비교하면 항상 false를 결과로 얻게 된다.
equals
메서드는 주소값으로 비교하기 때문에, 멤버변수의 값이 서로 같을지라도 참조변수의 값(주소값)이 다르면 false일 수 밖에 없다.
Object 클래스로부터 상속받은 equals 메서드는 결국 두개의 참조변수가 같은 객체를 참조하고 있는지, 즉 두 참조변수에 저장된 값(주소값)이 같은지를 판단하는 기능밖에 할 수 없다는 것을 알 수 있다.
equals
메서드로 인스턴스가 가지고 있는 value 값을 비교하도록 할 수 있는 방법은 equals 메서드를 오버라이딩하여 주소가 아닌 객체에 저장된 내용을 비교하도록 변경하면 된다.
class Person {
long id;
@Override
public boolean equals(Object obj) {
if(obj instanceof Person) {
return id == ((Person) obj.id); // obj가 Object 타입이므로 id 값을 참조하기 위해서는 Person 타입으로 형변환이 필요하다.
} else
return false; // 타입이 Person이 아니면 값을 비교할 필요도 없다.
}
Person(long id) {
this.id = id;
}
}
class EqualsEx {
public static void main(String[] args) {
Person p1 = new Person(12345L);
Person p2 = new Person(12345L);
if(p1 == p2)
System.out.println("p1과 p2는 같은 사람입니다.");
else
System.out.printf("p1과 p2는 다른 사람입니다."); // 첫번째 결과
if(p1.equals(p2))
System.out.println("p1과 p2는 같은 사람입니다."); // 두번째 결과
else
System.out.printf("p1과 p2는 다른 사람입니다.");
}
}
equals
메서드가 Person 인스턴스의 주소값이 아닌 멤버변수 id의 값을 비교하도록 하기 위해 equals
메서드를 다음과 같이 오버라이딩했다.
이렇게 함으로써 서로 다른 인스턴스일지라도 같은 id(주민등록번호)를 가지고 있다면, equals 메서드로 비교했을 때, true로 결과를 얻게 할 수 있다.
String
클래스 역시 Obejct 클래스의 equals 메서드를 그대로 사용하는 것이 아니라 이처럼 오버라이딩을 통해서 String 인스턴스가 갖는 문자열 값을 비교하도록 되어있다.
그렇기 때문에 같은 내용의 문자열을 갖는 두 String 인스턴스에 equals 메서드를 사용하면 항상 true 값을 얻을 수 있다.
hashCode 메서드는 해싱(hashing)기법
에 사용되는 ‘해시함수(hash function)’을 구현한 것이다.
해싱은 데이터관리기법 중 하나인데 다량의 데이터를 저장하고 검색하는데 유용하다.
해시함수는 찾고자 하는 값을 입력하면, 그 값이 저장된 위치를 알려주는 해시코드(hashcode)를 반환한다.
일반적으로 해시코드가 같은 두 객체가 존재하는 것은 가능하지만, Object 클래스에 정의된 hashCode
메서드는 객체의 주소값을 int 값으로 해시코드를 만들어 반환하기 때문에 32 bit JVM
에서는 서로 다른 두 객체는 결코 같은 해시코드를 가질 수 없었다.
하지만 64 bit JVM
에서는 8 byte 주소값으로 해시코드(4 byte)를 만들 수 있기 때문에 해시코드가 중복될 수 있다.
앞서 살펴본 것과 같이 클래스의 인스턴스 변수 값
으로 객체의 같고 다름을 판단해야 하는 경우라면 equals 메서드 뿐 만 아니라 hashCode
메서드도 적절히 오버라이딩해야 한다.
같은 객체라면 hashCode 메서드를 호출했을 때의 결과값인 해시코드도 같아야 하기 때문이다.
class HashCodeEx {
public static void main(String[] args) {
String str1 = new String("hello");
String str2 = new String("hello");
System.out.println(str1.hashCode()); // 12345
System.out.println(str2.hashCode()); // 12345
}
}
String
클래스는 문자열의 내용이 같으면, 동일한 해시코드를 반환하도록 hashCode
메서드가 오버라이딩되어 있기 때문에, 문자열 내용이 같은 str1과 str2에 대해 hashCode()
를 호출하면 항상 동일한 해시코드값을 얻는다.
반면에 Object
클래스의 hashCode
메서드처럼 객체의 주소값으로 해시코드를 생성하기 때문에 모든 객체에 대해 항상 다른 해시코드값을 반환할 것을 보장한다.
참고:
해싱기법
을 사용하는 HashMap이나 HashSet과 같은 클래스에 저장할 객체라면 반드시hashCode
메서드를 오버라이딩 해야한다. - 자바의 정석 11장. 컬렉션 프레임웍 -
우선 예제로 사용될 Product 클래스를 살펴보자.
public class Product {
private final String name;
public Product(String name) {
this.name = name;
}
// intellij Generate 기능 사용
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Product product = (Product) o;
return Objects.equals(name, product.name);
}
}
public static void main(String[] args){
Product product1 = new Product("아메리카노");
Product product2 = new Product("아메리카노");
// true 출력
System.out.println(product1.equals(product2));
}
equals를 재정의했기 때문에 Product 객체의 name이 같은 product1, product2 객체는 논리적으로 같은 객체로 판단된다.
이제 아래 main 메서드의 출력 결과를 예측해보자.
public static void main(String[] args) {
List<Product> products = new ArrayList<>();
products.add(new Product("아메리카노"));
products.add(new Product("아메리카노"));
System.out.println(products.size());
}
Product 객체를 2개 List<Product> products
에 넣어줬으니 출력 결과는 당연히 2일 것이다.
그렇다면 이번엔 Collection에 중복되지 않는 Product 객체만 넣으라는 요구사항이 추가되었다고 가정해보자.
요구사항을 반영하기 위해 List에서 중복 값을 허용하지 않는 Set으로 로직을 바꿨다.
public static void main(String[] args) {
Set<Product> products = new HashSet<>();
products.add(new Product("아메리카노"));
products.add(new Product("아메리카노"));
System.out.println(products.size());
}
추가된 두 Product 객체의 이름이 같아서 논리적으로 같은 객체라 판단하고 HashSet의 size가 1이 나올거라 예상했지만, 예상과 다르게 2가 출력된다.
hashCode를 equals와 함께 재정의하지 않으면 코드가 예상과 다르게 동작하는 위와 같은 문제를 일으킨다.
정확히 말하면 hash 값을 사용하는 Collection(HashSet, HashMap, HashTable)을 사용할 때 문제가 발생한다.
[1] hashCode 메서드의 리턴 값이 우선 일치하고 [2] equals 메서드의 리턴 값이 true여야 논리적으로 같은 객체
라고 판단한다.
앞서 봤던 main 메서드의 HashSet에 Product 객체를 추가할 때도 위와 같은 과정으로 중복 여부를 판단하고 HashSet에 추가됐다.
다만 Product 클래스에는 hashCode 메서드가 재정의 되어있지 않아서 Object 클래스의 hashCode 메서드가 사용되었다.
Object 클래스의 hashCode 메서드는 객체의 고유한 주소 값
을 int 값으로 변환하기 때문에 객체마다 다른 값을 리턴한다.
두 개의 Product 객체는 equals로 비교도 하기 전에 서로 다른 hashCode 메서드의 리턴 값으로 인해 다른 객체로 판단된 것이다.
public class Product {
private final String name;
public Product(String name) {
this.name = name;
}
// intellij Generate 기능 사용
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Product product = (Product) o;
return Objects.equals(name, product.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
intellij 의 Generate 기능을 사용했더니 Objects.hash 메서드를 호출하는 로직으로 hashCode 메서드가 재정의 됐다.
Objects.hash 메서드는 hashCode 메서드를 재정의하기 위해 간편히 사용할 수 있는 메서드이지만 속도가 느리다.
인자를 담기 위한 배열이 만들어지고 인자 중 기본 타입이 있다면 박싱과 언박싱도 거쳐야 하기 때문이다.
성능에 아주 민감하지 않은 대부분의 프로그램은 간편하게 Objects.hash 메서드를 사용해서 hashCode 메서드를 재정의해도 문제 없다.
민감한 경우에는 직접 재정의해주는 게 좋다. (관련 정보 - Guide to hashCode() in Java)
‘hash 값을 사용하는 Collection을 사용하지 않는다면, equals와 hashCode를 같이 재정의(오버라이딩)하지 않아도 되는건가?’ 라고 생각할 수 있다.
사용자정의 클래스를 작성할 때 equals 메서드를 오버라이딩해야 한다면, hashCode()도 클래스의 작성의도에 맞게 재정의하는 것이 원칙이지만, 요구사항에 따라 할지 말지 결정하면 된다.
(만약 Collection을 사용한다면 재정의 해주는게 맞다고 생각한다)
자바의 정석 - 9장. java.lang 패키지와 유용한 클래스 / 11장. 컬렉션 프레임웍
```java // 섬의 개수([백준] 4963.) - 23.12.04 import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.util.StringTokenizer;
public class Number_of_Islands { static final int MAX = 50 + 10; static boolean[][] map; static boolean[][] visited; static int M, N; static int[] dirY = {-1, -1, 0, 1, 1, 1, 0, -1}; // 상하좌우, 대각선 포함 - 8개 방향 static int[] dirX = {0, 1, 1, 1, 0, -1, -1, -1};
public static void dfs(int y, int x) { visited[y][x] = true; for(int i = 0; i < 8; i++) { int newY = y + dirY[i]; int newX = x + dirX[i]; if(map[newY][newX] && visited[newY][newX] == false) { dfs(newY, newX); } }
}
public static void main(String[] args) throws IOException { // 0. 입력 및 초기화 BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));