tools & libs/단위테스트(junit, spock)

[spock]가짜 객체를 이용한 테스팅 - 1

  • -
반응형

가짜 객체를 이용한 테스팅

프로젝트를 진행하다 보면 여러 가지 이유로 진짜 객체를 사용하지 못할 경우가 왕왕 발생한다.

예를 들어 웹페이지가 잘 동작하는지 알려면 HttpServletRequest를 날려야 하는데 서버까지 연동돼야 해서 쉽지 않다. DB 자료를 저장해야 하는데 테스트를 위해 DB까지 동작시키는게 곤란하거나 아직 has a 관계의 객체가 만들어지지 않은 상태에서 테스트 해야하는 등 상황은 매우 다양하다.

이런 상황에서의 테스트를 위해 Stub, Mock, Spy 와 같은 가짜 객체들이 사용된다.

 

테스트 상황

우리는 학생의 점수를 등록하고 조회하는 시스템을 구축중이다.  이 시스템은 ScoreServiceImpl이 Has a 관계로 ScoreRepository를 사용한다. 문제는 ScoreRepository는 interface로 기능이 선언만 된 상태이며 아직 미구현 단계이다. 어떻게 하면 효율적으로 ScoreServiceImpl을 테스트 할 수 있을까?

ScoreRepository

package com.quietjun.service;

import java.util.List;

public interface ScoreRepository {
    /**
     * 어떤 학생의 점수를 등록한다.
     * @param name
     * @param score
     * @throws IllegalStateException 점수가 음수거나 name이 null인 경우
     */
    void save(String name, int score) throws IllegalStateException;

    /**
     * name 학생의 점수를 반환한다.
     * @param name
     * @return
     * @throws IllegalStateException name에 해당하는 학생이 없을 경우
     */
    Integer getScore(String name) throws IllegalStateException;

    /**
     * 모든 시험 점수를 반환한다.
     * @return
     * @throws IllegalStateException 아직 점수가 하나도 없을 때
     */
    List<Integer> scores() throws IllegalStateException;

    /**
     * 등록된 점수의 개수를 반환한다.
     * @return
     */
    Integer size();
}

 

ScoreServiceImpl

package com.quietjun.service;

import java.util.List;
import java.util.stream.Collectors;

public class ScoreServiceImpl {
    private final ScoreRepository repo;

    public ScoreServiceImpl(ScoreRepository repo) {
        this.repo = repo;
    }

    public Integer sumScores() {
        List<Integer> scores = repo.scores();
        return scores.stream().collect(Collectors.summingInt(Integer::intValue));
    }

    public void save(String name, int score) {
        repo.save(name, score);
        printScore(name);
    }

    public void printScore(String name) {
        System.out.println(name + "의 점수는 " + repo.getScore(name));
    }
}

 

Stub

 

Stub이란?

Stub은 미리 정의된 데이터를 갖는 객체로 테스트 과정에서 호출에 응답할 수 있다.

Stub이 주로 사용되는 경우는 위의 ServiceRepository처럼 아직 안만들어져서 포함할 수 없거나 포함되기를 원치 않는 객체에 사용할 수 있다.실제 데이터를 사용하거나 바람직하지 않은 부작용(side effect)가 우려되는 객체도 마찬가지 이다.

예를 들어 sumScore라는 요청이 들어오는 상황에서 service는 repo의 scores를 호출해 학생들을 점수를 가져와서 더하려고 한다.

    public Integer sumScores() {
        // repo가 값을 전달해주면..
        List<Integer> scores = repo.scores();
        // 그 값들의 합을 반환한다.
        return scores.stream().collect(Collectors.summingInt(Integer::intValue));
    }

 

repo가 정상적으로 동작하려면 DB에 select 쿼리를 던져야 한다. 하지만 여기서 테스트 하려는 상황은 repo가 정확한 값을 주는가가 아니라 어떤 값을 대충 던져주면 service에서 '그 합을 반납할 수 있는가' 이다.  repo가 1,2,3과 같은 dummy 데이터를 반환하건 실제 값을 반환하건 service를 테스트 하는데는 상관이 없다는 것이다. 결국 굳이 DB를 연동할 필요는 없이 대충 지정된 값을 반환해주는 녀석이 있으면 된다. 이런 역할을 하는 것이 stub이다.

 

Stub 생성과 간단한 활용

Stub을 만들 때는 Stub() 생성자를 이용한다.

객체 생성 후 메서드 호출의 반환 값을 설정하려는 경우 >>를 이용한다.  호출 결과의 side effect를 지정하려는 경우 >> 에 {}를 연결해서 실행문을 작성할 수도 있다. 

다음은 ScoreRepository를 Stub으로 생성하고 ScoreServiceImpl과 함께 공통 객체로 사용하는 모습을 보여준다. ScoreServiceImpl의 @Subject는 테스트 대상을 명시적으로 지정하는 애너테이션으로 의미론적이며 기능은 없다.

    // 공통 객체
    // ScoreRepository가 아직 없거나 side effect가 우려되므로 Stub을 써보자.
    ScoreRepository repo = Stub()
    
    @Subject        // 실제로 테스트할 객체는 service이다.
    ScoreServiceImpl service = new ScoreServiceImpl(repo);

    def "ScoreService의 sumScores가 잘 동작하는지 테스트()"() {
        setup:
        repo.scores() >> Arrays.asList(1, 2, 3);

        when:
        Integer sum = service.sumScores()

        then:
        sum == 6
    }

ScoreRepository는 Stub이기 때문에 그냥 깡통이라고 생각하면 된다. 따라서 아무런 일도 하지 못한다. 실제 동작을 시키려면 테스트 feature의 setup 영역에서 처럼 repo.scores() 가 호출되면 List 형태로 [1,2,3]을 반환하게 한다.

이제 when block에서 sumScores를 호출하면 내부적으로 repo의 scores를 호출하고 stub은 설정에 따라 [1,2,3]을 반환하게 된다. 서비스는 이 값을 받아 합으로 6을 리턴한다.

then block에서 sum과 정확한 계산값인 6을 비교하므로 테스트는 성공한다.

 

side effect  설정

stub을 통해서 단순히 값을 반환하는것 뿐 아니라 다양한 실행문을 작성하거나 예외도 던질 수 있다. 이를 위해서는 >> 에 closure({...})를 등록한다.

    def "기본적인 스텁의 동작 확인-가짜 반환값이나 side effect는 >> "() {
        setup:
        // Stub의 기능은 여기서 그냥 만드는 것이다.
        repo.size() >> 10

        // 실행문이 필요하다면 {} 즉 closure를 사용
        repo.scores() >> {throw new IllegalStateException("아직 등록된 점수가 없다.")}

        repo.toString() >>{
            println "객체의 내용을 리턴할 수있다."
            return "이것은 repo의 내용이다."
        }
        
        when: "repo의 size를 호출하면 10이 반환 된다."
        int size = repo.size();
        then:
        size==10

        when: "repo의 scores를 호출하면 IllegalStateException이 발생한다."
        List<Integer> scores = repo.scores()
        then:
        thrown(IllegalStateException)
        
        when: "repo의 toString을 호출하면 어떤 내용이 출력되고 '이것이 repo의 내용이다'가 반환된다."
        String result = repo.toString()
        then:
        result =="이것은 repo의 내용이다."
    }

 

호출 회수에 따른 값 반환

stub 메서드가 호출되는 회수에 맞춰서 다른 값을 순차적으로 지정할 수도 있는데 이때는 >>>를 사용한다.

    def "호출 회수에 따라 매번 다른 값이 반환되게 하려면 >>> "() {
        given:
        repo.size() >>> [1,3,5,7]

        when:
        int size = repo.size()
        then:
        size==1

        expect:
        repo.size() ==3
        repo.size() ==5
        repo.size() ==7
    }

 

파라미터에 따른 동작 지정

단순히 메서드 이름으로 뿐 아니라 파라미터를 이용해서도 서로 다른 결과를 만들어 줄 수 있다.

    def "파라미터 값에 따른 다양한 동작을 처리하기 위해서는 "() {
        setup:
        repo.getScore("홍길동") >> 10
        repo.getScore("임꺽정") >> 25
        repo.getScore("장길산") >> 35

        expect:
        repo.getScore(name) == score

        where:
        name        | score
        "홍길동"    | 10
        "임꺽정"    | 25
        "장길산"    | 35
    }

 

전달된 파라미터의 검증

앞선 예에서는 단지 파라미터와 같은 상황에서만 동작했다면 전달된 파라미터의 값을 이용해 다양한 동작의 처리도 가능하다. 이를 위해 파라미터 영역에 클로저를 사용하는데 내부는 마치 자바의 람다식과 유사하다. 이 클로저의 반환 값이 true이면 실행될 블럭을 역시 >>와 closure로 설정한다.

    def "전달된 name이 null 이거나 2글자 미만이면 예외가 발생한다."() {
        given:
        repo.getScore({String name -> name==null || name.length()<2}) >> {
            throw new IllegalStateException("이름은 반드시 2글자 이상이 필요해")
        }

        repo.getScore({String name -> name.length()>=2}) >> {
            return 10
        }
        
        when:
        service.printScore("홍")
        then:
        thrown(IllegalStateException)

        when:
        service.printScore("홍길동")
        then:
        noExceptionThrown()
    }

 

와이드 카드 _ 이용

특정 파라미터가 아니라 그냥 어떤 값을 표현할 때는 와이드 카드로 _를 사용한다. _는 아래와 같이 다양한 표현을 가질 수 있다.

    // 아규먼트의 타입에 재약을 두지 않음
    list.contains(_) >> true
    
    // 아규먼트는 Integer 타입으로 들어오면
    list.add(_ as Integer) >> true
    
    // 아규먼트가 null이 아닌게 들어오면
    list.add(!null) >> true

다음은 와이드카드를 이용한 검증의 예이다.

    def "와이드카드_를 이용해보자"() {

        setup:
        List<Object> list = Stub();
        list.add(_ as Integer) >> { throw new IllegalStateException() }

        when:
        list.add(2)
        then:
        thrown(IllegalStateException.class)

        when:
        list.add("Hello")
        then:
        notThrown(IllegalStateException.class);
    }

 

와이드 카드를 이용한 파라미터 검증

[전달된 파라미터의 검증] 섹션에서와 유사하게 와이드 카드를 이용해서도 파라미터 검증이 가능하다.

    def "두 개의 파라미터를 전달해야 한다면?"() {
        setup:
        repo.save(_,_) >>{
            String name, int score ->
                if(name==null || name.length()<2){
                    throw new IllegalStateException("이름은 반드시 2글자 이상이 필요해")
                }
                if(score<0){
                    throw new IllegalStateException("점수는 양수")
                }
                printf "입력값 확인: %s, %d%n", name, score
        }

        when:
        repo.save(null, 10)
        then:
        def e = thrown(IllegalStateException)
        e.getMessage() == "이름은 반드시 2글자 이상이 필요해"
        
        when:
        repo.save("홍길동", -1)
        then:
        e = thrown(IllegalStateException)
        e.getMessage() == "점수는 양수"
        
        when:
        repo.save("홍길동", 10)
        then:
        noExceptionThrown()
    }

 

반응형

'tools & libs > 단위테스트(junit, spock)' 카테고리의 다른 글

[junit] static method mocking  (0) 2023.10.19
[spock]가짜 객체를 이용한 테스팅 - 2  (4) 2021.02.16
[spock]기본 사용법  (0) 2021.02.10
[spock]Spock이란?  (0) 2021.02.09
[spock]BDD와 Spock  (0) 2021.02.08
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.