Post

[Book - JUnit IN ACTION 3판] 13. JUnit 5와 지속적 통합

테스트와 지속적 통합

JUnit 테스트를 개발 주기 안에 포함하는 것은 굉장히 중요한 일이다.

  • 일반적인 개발 주기
    • 코드 작성 > 구동 > 테스트 > 코드 작성
  • TDD를 적용한 개발 주기
    • 테스트 > 코드 작성 > 구동 > 테스트

모듈은 서로 통신하게끔 되어 있다.
그러므로 모든 모듈을 한데 모았을 때 함께 잘 동작하는지 확인할 필요가 있다.
이렇게 애플리케이션 전체를 테스트하기 위해서는 통합 테스트나 기능 테스트 같은 테스트가 필요하다.

TDD는 더 일찍, 더 자주 테스트하는 것을 권장한다.
하지만 사소한 수정 하나하나에 단위 테스트, 통합 테스트, 기능 테스트를 다 수행해야 한다면 개발 진행 속도는 무척 느려질 것이다.
이런 일이 일어나지 않도록 개발을 진행하고 있을 때 되도록 빨리, 자주 단위 테스트를 돌려 보자.

통합 테스트는 개발 프로세스와는 독립적으로 수행하는 게 좋다.
특히 주기적으로 실행되는 것이 가장 좋다.
이렇게 하면 문제가 생겼을 때 주기적으로 문제에 관한 알림을 받을 수 있고, 빠른 시간 내에 문제를 해결할 수 있다.

지속적 통합
개발자들이 되도록 자주 작업의 결과물을 통합해야 한다는 소프트웨어 개발 실천 방법이다.
일반적으로 최소 1일 1회 이상 통합을 권장한다.
대부분의 통합은 자동화된 빌드와 테스트 과정을 거쳐 검증이 되는데, 이러한 과정을 통해 통합 시 발생하는 문제를 매우 빠르게 찾아낼 수 있다.
지속적 통합을 통해 문제를 획기적으로 줄일 수 있고, 더욱 응집력 있는 소프트웨어를 빠르게 개발할 수 있다.


주기적인 간격으로 통합 테스트를 실행하려면 관련된 시스템 모듈이 이미 준비되고 빌드되어 있어야 한다.
모듈이 빌드되고 통합 테스트가 실행된 후에는 가능한 한 빨리 결과를 확인해야 한다.
그러므로 아래와 같은 단계를 모두 자동화하기 위해서는 적절한 도구가 필요하다.

  1. 항상 관리 시스템에서 프로젝트를 체크아웃(복사)한다.
  2. 각 모듈을 빌드하고 단위 테스트를 실행하여 각 모듈이 격리된 상태에서 예상대로 동작하는지 검증한다.
  3. 통합 테스트를 실행하여 모듈 간 통합이 예상대로 잘 되었는지 검증한다.
  4. 3단계에서 실행한 테스트 결과를 리포트로 만든다.


아래는 지속적 통합 과정에 대한 그림이다.

CI procedure

  1. 지속적 통합 도구는 주로 프로젝트를 가져오기 위해 형상 관리 시스템과 함께 사용한다.
  2. 빌드 도구를 사용하여 프로젝트를 빌드한다.
  3. CI 도구를 사용하여 각종 테스트를 실행한다.
  4. 지속적 통합 도구로 결과를 게시하고 모두가 알 수 있도록 알림을 보낸다.

위 네 단계는 매우 일반적인 과정이며 개선할 여지가 많다.

예를 들어 빌드 시작 전에 버전 관리 시스템에서 변경이 일어났는지 먼저 확인할 수 있다.
변경이 없었다면 컴퓨터의 CPU 성능만 낭비하는 꼴이 된다.
왜냐하면 당연히 똑같은 결과를 얻을 것이기 때문이다.

위와 같은 개선방안을 고려해서 프로젝트를 지속적으로 통합하기 위한 도구로 젠킨스를 사용해 보자.


젠킨스 살펴보기

젠킨스를 설치하고 실행해 보자.

  1. 맥북을 사용중이므로 cmd에 brew install jenkins-lts로 젠킨스를 설치한다.
  2. cmd에 brew services restart jenkins-lts로 젠킨스를 재실행한다.
  3. 실행 후에 127.0.0.1:8080 으로 접속해서 비밀번호를 입력한다.
    • 비밀번호는 화면에 나오는 경로를 참고해서 cmd에 cat 명령어로 찾으면 된다.
  4. 로그인 후에는 어드민 계정을 만들자.

지속적 통합 실천하기

항공편 관리 시스템 중 승객에 관한 부분과 항공편에 관한 부분을 따로 개발 및 단위 테스트를 해보자.

승객 단위 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import lombok.Getter;
import java.util.Arrays;
import java.util.Locale;

@Getter
public class Passenger {

    private String identifier;
    private String name;
    private String countryCode;

    public Passenger(String identifier, String name, String countryCode) {
        if (!Arrays.asList(Locale.getISOCountries()).contains(countryCode)) {
            throw new RuntimeException("국가 코드가 적절하지 않습니다.");
        }
        this.identifier = identifier;
        this.name = name;
        this.countryCode = countryCode;
    }

    @Override
    public String toString() {
        return "Passenger " + getName()
            + " with identifier: " + getIdentifier()
            + " from " + getCountryCode();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

public class PassengerTest {

    @Test
    public void testPassengerCreation() {
        Passenger passenger = new Passenger("123-45-6789", "John Smith", "US");
        assertNotNull(passenger);
    }

    @Test
    public void testInvalidCountryCode() {
        assertThrows(RuntimeException.class, () -> {
                    Passenger passenger = new Passenger("900-45-6789", "John Smith", "GJ");
                });
    }

    @Test
    public void testPassengerToString() {
        Passenger passenger = new Passenger("123-45-6789", "John Smith", "US");
        assertEquals("Passenger John Smith with identifier: 123-45-6789 from US", passenger.toString());
    }
}
  • testPassengerCreation()
    • 올바른 파라미터를 사용하여 승객을 생성했는지 검증한다.
  • testInvalidCountryCode()
    • countryCode가 유효한지 검증한다.
  • testPassengerToString()
    • toString() 메서드가 올바르게 동작하는지 검증한다.

항공편 단위 테스트

항공편에 관한 부분을 개발하면서 Passenger에 추가 코드가 필요한데 뒤에서 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import lombok.Getter;
import org.example.junitinaction3.chapter13.passengers.Passenger;

import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Getter
public class Flight {

    private String flightNumber;
    private int seats;
    private Set<Passenger> passengers = new HashSet<>();

    private static String flightNumberRegex = "^[A-Z]{2}\\d{3,4}$";
    private static Pattern pattern = Pattern.compile(flightNumberRegex);

    public Flight(String flightNumber, int seats) {
        Matcher matcher = pattern.matcher(flightNumber);
        if (!matcher.matches()) {
            throw new RuntimeException("항공편명이 적절하지 않습니다.");
        }
        this.flightNumber = flightNumber;
        this.seats = seats;
    }

    public int getNumberOfPassengers() {
        return passengers.size();
    }

    public boolean addPassenger(Passenger passenger) {
        if (getNumberOfPassengers() >= seats) {
            throw new RuntimeException(getFlightNumber() + " 항공편에 좌석이 부족합니다.");
        }
        passenger.setFlight(this);
        return passengers.add(passenger);
    }

    public boolean removePassenger(Passenger passenger) {
        passenger.setFlight(null);
        return passengers.remove(passenger);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class FlightTest {

    @Test
    public void testFlightCreation() {
        Flight flight = new Flight("AA123", 100);
        assertNotNull(flight);
    }

    @Test
    public void testInvalidFlightNumber() {
        assertThrows(RuntimeException.class, () -> {
            Flight flight = new Flight("AA12", 100);
        });
    }
}
  • testFlightCreation()
    • 파라미터를 적절하게 사용해서 항공편 객체를 생성했는지 검증한다.
  • testInvalidFlightNumber()
    • flightNumber 파라미터가 유효하지 않다면 Flight 객체를 생성할 때 예외를 던지는지 검증한다.

승객, 항공편 통합 테스트

지금까지 Passenger와 Flight 각각에 대한 단위 테스트를 해봤다.
이제 Passenger와 Flight 클래스 간의 통합 테스트를 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import org.example.junitinaction3.chapter13.flights.Flight;
import org.example.junitinaction3.chapter13.passengers.Passenger;
import org.junit.jupiter.api.Test;

import java.io.IOException;

import static org.junit.jupiter.api.Assertions.*;

public class FlightWithPassengersTest {

    private Flight flight = new Flight("AA123", 1);

    @Test
    public void testAddRemovePassengers() throws IOException {
        Passenger passenger = new Passenger("124-56-7890", "Michael Johnson", "US");
        assertTrue(flight.addPassenger(passenger));
        assertEquals(1, flight.getNumberOfPassengers());
        assertEquals(flight, passenger.getFlight());

        assertTrue(flight.removePassenger(passenger));
        assertEquals(0, flight.getNumberOfPassengers());
        assertEquals(null, passenger.getFlight());
    }

    @Test
    public void testNumberOfSeats() {
        Passenger passenger1 = new Passenger("124-56-7890", "Michael Johnson", "US");
        flight.addPassenger(passenger1);
        assertEquals(1, flight.getNumberOfPassengers());

        Passenger passenger2 = new Passenger("127-23-7991", "John Smith", "GB");
        assertThrows(RuntimeException.class,
                () -> flight.addPassenger(passenger2));
    }
}
  • Flight flight = new Flight("AA123", 1)
    • 테스트를 쉽게 할 목적으로 올바른 항공편명과 한 개의 좌석을 가진 항공편 객체를 미리 생성한다.
  • testAddRemovePassengers()
    • 승객 객체 생성, 추가, 삭제
      • 각 단계에서 작업이 성공했는지 검증
    • 항공편에 속한 승객의 수가 정확한지도 검증
  • testNumberOfSeats()
    • 승객 객체를 생성하여 항공편에 승객을 추가한 후, 항공편의 승객 수가 허용 좌석 수 1인지 확인
    • 항공편의 허용 좌석수를 초과하도록 승객 객체를 하나 더 생성
      • 항공편에 승객을 추가하여 승객 수가 좌석 수를 초과하면 예외가 발생하는지 확인

젠킨스 설정하기

책과 블로그를 참고하여 실행했던 젠킨스 웹 인터페이스에서 젠킨스 잡을 만들어 보자.
젠킨스 잡에 프로젝트를 등록하기 위해 git에 프로젝트를 올렸다.

설정하면서 시행착오가 좀 있었는데,
젠킨스에 사용하고 싶은 경로가 Git Repository가 아니라,
Git Repsitory 안에 있는 특정 폴더를 설정하고 싶었다.

아래는 해결한 젠킨스 Item 설정 부분이다.

  • Repositories
    • Repository URL
      • Git Repository의 Clone using the web URL
    • Additional Behaviours에서 Add 셀렉트 버튼을 클릭한 후 Sparse Checkout paths를 추가한다.
      • 추가된 Sparse Checkout paths에서 Path에 특정 폴더 경로 pre-study/tdd-study/junitinaction3를 적어줬다.
  • Build Steps
    • Use Gradle Wrapper
      • Wrapper location에 특정 폴더 경로 pre-study/tdd-study/junitinaction3를 적어줬다.
    • Tasks
      • clean build를 적어줬다.

지속적 통합 환경에서 작업하기

젠킨스에서 위의 코드를 그대로 빌드하게 되면 오류가 발생한다.

Passenger 클래스와 Flight 클래스가 따로 개발이 되었다는 환경에서
Flight 클래스에서 Passenger를 추가하거나 빼야하는데 따로 개발된 점에서 Passenger 클래스에 그 기능이 없기 때문이다.

문제를 알았으니 Passenger 클래스에 Flight 인스턴스 변수를 추가하고 서로 양방향 참조가 가능하도록 각 클래스에 필요한 기능을 구현하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import lombok.Getter;
import lombok.Setter;
import org.example.junitinaction3.chapter13.flights.Flight;

import java.util.Arrays;
import java.util.Locale;

@Getter
@Setter
public class Passenger {

    private String identifier;
    private String name;
    private String countryCode;

    private Flight flight;

    public Passenger(String identifier, String name, String countryCode) {
        if (!Arrays.asList(Locale.getISOCountries()).contains(countryCode)) {
            throw new RuntimeException("국가 코드가 적절하지 않습니다.");
        }
        this.identifier = identifier;
        this.name = name;
        this.countryCode = countryCode;
    }

    public void joinFlight(Flight flight) {
        Flight previousFlight = this.flight;
        if (null != previousFlight) {
            if (!previousFlight.removePassenger(this)) {
                throw new RuntimeException("승객을 삭제할 수 없습니다");
            }
        }
        setFlight(flight);
        if (null != flight) {
            if (!flight.addPassenger(this)) {
                throw new RuntimeException("승객을 추가할 수 없습니다");
            }
        }
    }

    @Override
    public String toString() {
        return "Passenger " + getName()
                + " with identifier: " + getIdentifier()
                + " from " + getCountryCode();
    }
}
  • Flight 타입 필드가 추가되었다.
  • joinFlight() 메서드가 추가되었다.
    • 승객의 이전 항공편 정보를 가져온 다음 이전 항공편 정보가 존재한다면 이전 항공편에서 승객을 삭제한다.
    • 만약 삭제가 실패한다면 예외를 던진다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import lombok.Getter;
import org.example.junitinaction3.chapter13.passengers.Passenger;

import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Getter
public class Flight {

    private String flightNumber;
    private int seats;
    private Set<Passenger> passengers = new HashSet<>();

    private static String flightNumberRegex = "^[A-Z]{2}\\d{3,4}$";
    private static Pattern pattern = Pattern.compile(flightNumberRegex);

    public Flight(String flightNumber, int seats) {
        Matcher matcher = pattern.matcher(flightNumber);
        if (!matcher.matches()) {
            throw new RuntimeException("항공편명이 적절하지 않습니다.");
        }
        this.flightNumber = flightNumber;
        this.seats = seats;
    }

    public int getNumberOfPassengers() {
        return passengers.size();
    }

    public boolean addPassenger(Passenger passenger) {
        if (getNumberOfPassengers() >= seats) {
            throw new RuntimeException(getFlightNumber() + " 항공편에 좌석이 부족합니다.");
        }
        passenger.setFlight(this);
        return passengers.add(passenger);
    }

    public boolean removePassenger(Passenger passenger) {
        passenger.setFlight(null);
        return passengers.remove(passenger);
    }
}
  • addPassenger() 메서드와 removePassenger() 메서드에서의 passenger.setFlight()가 정상 작동하게 된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import org.example.junitinaction3.chapter13.flights.Flight;
import org.example.junitinaction3.chapter13.passengers.Passenger;
import org.junit.jupiter.api.Test;

import java.io.IOException;

import static org.junit.jupiter.api.Assertions.*;

public class FlightWithPassengersTest {

    private Flight flight = new Flight("AA123", 1);

    @Test
    public void testPassengerJoinsFlight() {
        Passenger passenger = new Passenger("123-45-6789", "John Smith", "US");
        passenger.joinFlight(flight);
        assertEquals(flight, passenger.getFlight());
        assertEquals(1, flight.getNumberOfPassengers());
    }

    @Test
    public void testAddRemovePassengers() throws IOException {
        Passenger passenger = new Passenger("124-56-7890", "Michael Johnson", "US");
        assertTrue(flight.addPassenger(passenger));
        assertEquals(1, flight.getNumberOfPassengers());
        assertEquals(flight, passenger.getFlight());

        assertTrue(flight.removePassenger(passenger));
        assertEquals(0, flight.getNumberOfPassengers());
        assertEquals(null, passenger.getFlight());
    }

    @Test
    public void testNumberOfSeats() {
        Passenger passenger1 = new Passenger("124-56-7890", "Michael Johnson", "US");
        flight.addPassenger(passenger1);
        assertEquals(1, flight.getNumberOfPassengers());

        Passenger passenger2 = new Passenger("127-23-7991", "John Smith", "GB");
        assertThrows(RuntimeException.class,
                () -> flight.addPassenger(passenger2));
    }
}
  • testPassengerJoinsFlight()
    • 승객을 생성하고, 항공편을 생성한다.
    • 승객 관점에서 항공편을 추가한다.
    • 승객의 항공편이 이전에 정의한 항공편인지 검증한다.
    • 해당 항공편의 승객 수가 현재 한 명인지 검증한다.
  • testAddRemovePassengers() 메서드와 testNumberOfSeats() 메서드에서의 부족했던 기능들이 정상 작동하게 된다.

젠킨스에서 다시 빌드를 해보니 드디어 성공했다!!


정리

  • 통합 테스트를 하기 위해서는 적절한 도구가 필요하다.
  • 통합 테스트를 하기 위해 대표적으로 젠킨스를 사용한다.
  • 젠킨스에서 설정을 통해 원하는 디렉터리만 빌드할 수도 있다.
  • 젠킨스로 CI를 경험해 보면서 통합과 관련한 문제를 신속하게 확인할 수 있었다.
    • 추후 개발자가 문제를 즉시 해결할 수 있을 것 같다.
This post is licensed under CC BY 4.0 by the author.

© Yn3. Some rights reserved.