본문 바로가기

Develop/Springboot

크롤링 테스트를 위한 mock server test 구축

Java의 Jsoup을 이용해서 페이지를 크롤링을 하는 코드는 찾아보면 많지만, 크롤링을 테스트하는 코드는 찾기 어려웠다. 따라서 크롤링 테스트를 짜기 위해 직접 nginx 서버를 띄어서 그 서버를 크롤링 하기도 하는 등 JavaBom 스터디원과 구현해본 크롤링 테스트에 대한 포스팅을 하게되었다.

참고로 nginx 서버를 띄어서 크롤링 하는것은 실제 서버라서 크롤링 테스트의 의미가 없는 것 같다. 
실제 서버가 죽으면 크롤링 테스트도 못하므로 결국은 @Ignore를 걸어야하는 테스트이기 때문이다.

내가 생각하기에 크롤링 테스트에서 쟁점은 2가지 이다.

1. Parsing 테스트

[ 목적 ]
페이지의 html 파일에서 크롤링을 통해 원하는 정보만을 가져왔는지 체크한다.

[ 구현 방식 ]
크롤링을 원하는 페이지를 ctrl+s 를 이용해서 .html 파일로 받아온 후, 해당 html 파일을 내 springboot project의 resource에 저장한다. 이후 이 파일을 받아와, 내가 만든 크롤링 코드를 돌려서, 내가 파싱한 정보가 원하는 대로 크롤링이 잘 되었는지 확인한다.
  ex ) 네이버 영화 html 에서 찾아온 영화 리스트의 항목이 10개가 맞는가
  ex ) 크롤링 한 영화의 title이 "마션"이 맞는가 등등 (젤 좋아하는 영화ㅋ)

2. connect 테스트

[ 목적 ]
크롤링으로 해당 페이지를 받아오겠다는 요청을 보내고 응답을 받아 내가 만든 크롤링 코드가 잘 동작하는지를 체크한다.

[ 구현방식 ]
test를 위한 Mockserver를 하나 생성한다. 우리가 보는 영화 페이지가 GET 메서드로 URL 요청을 보낼 경우 html 페이지를 리턴하는 것처럼, jsoup connect의 결과를 확인하기 위한 mock server를 만드는 것이다. 이 mock server 역시 내가 크롤링을 원하는 네이버 영화 페이지처럼 특정 URL 을 GET 메소드를 통해 호출할 경우 특정 html 파일을 보내도록 지정한다.

[ 기능 ]
1. 고정된 response를 만들고 return 할 수 있다.
2. request를 다른 서버에 forwarding 한다.
3. callbacks 실행이 가능하다.
4. request를 확인할 수 있다.


오늘 포스팅을 위해 사용할 크롤링 코드 + 페이지는 네이버 영화의 랭킹 페이지를 이용하려 한다. 

https://movie.naver.com/movie/sdb/rank/rmovie.nhn

0. 디렉토리 구조

테스트 디렉토리 구조와 크롤링 디렉토리 구조

[포스팅 관련 코드] https://github.com/mjung1798/Jyami-Java-Lab/tree/master/crawler-mock-server-test

 

mjung1798/Jyami-Java-Lab

Jyami의 Spring boot 및 Java 실험소. Contribute to mjung1798/Jyami-Java-Lab development by creating an account on GitHub.

github.com

1. Jsoup을 이용한 Crawling

네이버 영화의 랭킹페이지

여기서 랭킹 항목의 순위와, 영화명, 그리고 연결 페이지 링크를 크롤링 하겠다.

# build.gradle

dependencies {
	implementation 'org.jsoup:jsoup:1.11.3'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
}

Jsoup을 이용해 크롤링을 했고, Builder를 사용하려고 lombok을 넣어주었다.

// NaverMovieCrawler.java

public class NaverMovieCrawler {

    public static void main(String[] args) {
        Document document = new NaverMovieCrawler().getCrawlingResult("https://movie.naver.com/movie/sdb/rank/rmovie.nhn");
        TopMovieList topMovieList = new TopMovieList(document);
        System.out.println(topMovieList.toString());
    }

    // url 을 보내면, 해당 페이지를 크롤링하여 Document 타입을 리턴한다.
    public Document getCrawlingResult(String url){
        try {
            return Jsoup.connect(url)
                    .timeout(2000)
                    .get();
        } catch (IOException e) {
            throw new RuntimeException("crawling 실패");
        }
    }
}

스프링 부트로 API를 만들어도 좋지만, 일단은 간단하게 main을 만들어주고 돌려보았다.
여기서 중요한게 Jsoup.connect(url) 이부분인데, mockserver 테스트를 하는 이유라고 볼 수 있다. Jsoup의 connect() 메소드는 기본적으로 get 매핑을 요청하며, 이외에도 Jsoup에서 제공하는 다양한 메서드를 체이닝해서 좀 더 구체적인 요청을 보낼 수 있다.

그러나 크롤링하려는 네이버 영화 페이지의 html 파일을 받는 요청이므로 Get 메서드 + URL 매핑으로 간단하게 connect() 메소드를 마무리 할 수 있다.

@AllArgsConstructor
@Getter
public class TopMovieList {

    private List<Movie> topMovies = new ArrayList<>();

    public TopMovieList(Document document){
        this.topMovies = document.select("table.list_ranking tr").stream()
                .filter(x -> !x.select(".ac").isEmpty())
                .map(Movie::of)
                .collect(Collectors.toList());
    }

    @Override
    public String toString() {
        return topMovies.stream()
                .map(Movie::toString)
                .collect(Collectors.joining(", \n"));
    }
}

main에서 보면 알다싶이 나는 TopMovieList 라는 일급컬렉션 객체를 만들었다. Jsoup을 이용해 받아온 Document객체를 (html 전체를 리턴한다) 파싱하여 List<Movie>를 만든다. 이때 Movie 객체 하나하나를 만들기 위해서 네이버 영화 랭킹의 테이블 row를 하나씩 분리해서 Movie 객체의 of() 메서드를 이용해서 테이블 row 하나하나에서의 데이터를 분리하는 파싱은 Movie 객체에 맡긴다.

// Movie.java

public class Movie {
    private int rank;
    private String title;
    private String detailLink;

    @Builder
    private Movie(int rank, String title, String detailLink) {
        this.rank = rank;
        this.title = title;
        this.detailLink = detailLink;
    }

    public static Movie of (Element element){
        return Movie.builder()
                .rank(Integer.parseInt(element.select(".ac img").attr("alt")))
                .title(element.select(".title").text())
                .detailLink(element.select(".title a").attr("href"))
                .build();
    }

    @Override
    public String toString() {
        return "Movie{" +
                "rank='" + rank + '\'' +
                ", title='" + title + '\'' +
                ", detailLink='" + detailLink + '\'' +
                '}';
    }
}

Movie 객체에서는 List<Movie>의 생성자에서 받아온 네이버 영화 랭킹 테이블의 row 한 줄인 Element를 인자로 받아, 데이터를 바인딩해준다. 개발자도구를 이용해 내가 원하는 데이터만을 분리할 수 있도록 css selector를 적절히 사용한다.

> Task :NaverMovieCrawler.main()
Movie{rank='1', title='남산의 부장들', detailLink='/movie/bi/mi/basic.nhn?code=176306'}, 
Movie{rank='2', title='히트맨', detailLink='/movie/bi/mi/basic.nhn?code=185838'}, 
Movie{rank='3', title='미스터 주: 사라진 VIP', detailLink='/movie/bi/mi/basic.nhn?code=177509'}, 
Movie{rank='4', title='해치지않아', detailLink='/movie/bi/mi/basic.nhn?code=180025'}, 
... // 생략

이렇게 만들어진 List<Movie> 객체를 toString()을 이용해 main에서 출력하면 위와 같이 확인 할 수 있어, 크롤링이 잘되었음을 확인 할 수 있다.

 

2. MockServer 생성하기

mock-server 생성과 테스트를 위한 모듈을 추가해준다. test는 junit5를 이용해서 했다.

# build.gradle

test {
	useJUnitPlatform()
}

dependencies {
	testCompile group: 'org.mock-server', name: 'mockserver-netty', version: '5.8.1'
}

가장 먼저 크롤링 하려는 네이버 영화 페이지로 가서 html을 다운받는다. 내가 request와 response를 지정한 mock-server가 네이버 영화 페이지의 html 파일을 리턴해야하기 때문이다.

나는 ranking_naver_move.html 이라는 이름으로 저장했다. 그리고 이렇게 다운 받은 파일을 test > resources 폴더 안에 넣는다.
추가로 초반에 말한 parsing test와 관련해서 테스트 코드를 구현하느라 나는 하나의 html 파일이 더 생기게되었다.

mock-server와 관련한 테스트를 할 경우에, 테스트 시작 전, mock-server를 열어주어야한다. 그리고 테스트가 끝나면 mock-server를 닫아주어야한다. 따라서 테스트코드의 시작과 전에 해당 메소드를 써준다.

// NaverMovieCrawlerTest.java

public class NaverMovieCrawlerTest {

    private static final int PORT = 9000;
    private static ClientAndServer mockServer;

    @BeforeEach
    void setUp() {
        mockServer = ClientAndServer.startClientAndServer(PORT);
        System.out.println("mock server start");
    }

    @AfterEach
    void tearDown() {
        mockServer.stop();
        System.out.println("mock server stop");
    }

@Test 어노테이션을 붙인 빈 테스트를 만들고 이 테스트를 돌려보면 아래와 같이 엄청난 로그가 뜬다. 몇몇 눈에 띄는 로그를 읽어보면 java version, cachesize 등을 자동으로 설정함을 알 수 있다.

또한, 인자로 PORT값 즉 9000을 넣은 것은 잠깐 뜨는 mock-server의 포트를 지정한 것인데, 실제로 sout을 찍은 부분 이후 즉, startClientAndServer 메소드가 실행된 이후, 9000포트로 mock-server가 설정되었다는 로그가 출력됨을 알 수 있다. 

이제 mock-server의 자세한 세팅을 해보자. 내가 구현하려는 mock-server는 네이버 영화 랭킹 페이지와 같은 역할을 수행해야한다. 따라서 네이버 랭킹 페이지를 나타내는 path인 "/movie/sdb/rank/rmovie.nhn"을 입력할 경우 html 파일을 리턴하도록 mock-server의 기능을 설정해주어야 한다. 이는 아래 보다 싶이 .when()과 .response() 메소드를 이용해서 설정할 수 있다.

when 부분에는 mock-server에서 받을 request 그리고 respond 부분에는 when에서 정의한 request를 받았을 때 실행할 response 값을 설정해 준다.

가장 먼저 mock-server host인 localhost, 그리고 mock-server를 실행할 port를 적어준다. 이때 mock-server를 가장먼저 시작할 때 사용하는 메서드인 ClientAndServer.startClientAndServer() 에서 만든 mockserver의 port를 적어주었기 때문에, 이때 사용한 port와 똑같이 적어준다.  따라서 PORT(9000)을 할당하였다. 

// NaverMovieCrawlerTest.java

private void createNaverRankingPageServer(String filePath){
        byte[] response = readHtmlFile(filePath);

        new MockServerClient("localhost", PORT)
                .when(
                        request()
                                .withMethod("GET")
                                .withPath("/movie/sdb/rank/rmovie.nhn")
                )
                .respond(
                        response()
                                .withStatusCode(200)
                                .withBody(response)
                );
}

request로는 네이버 영화 랭킹 페이지의 URL인 "/movie/sdb/rank/rmovie.nhn"를 GET 메소드로 호출하도록 정의하고, 이 경우에는
response는 statusCode 200과 함께 랭킹페이지 html을 넘겨주도록 지정하였다. (response 변수)

이때 response 변수는 아까 저장한 네이버 영화 랭킹 페이지의 html 파일이 리턴되어야 하므로, 아래와 같이 Stream 형태로 html 파일의 내용을 받아오는 로직을 짰다. (인자로는 리턴 받을 html 파일을 적어준다)

// NaverMovieCrawlerTest.java

private byte[] readHtmlFile(String filePath) {
        InputStream resourceAsStream = getClass().getClassLoader()
                .getResourceAsStream(filePath);
        try {
            assert resourceAsStream != null;
            return IOUtils.toByteArray(resourceAsStream);
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException("file IO 실패");
        }
}

mock-server 세팅까지 한 후에, @Test에 createNaverRankingPageServer() 메서드만 적어서 실행시키면 또 여러 로그가 나오는데, 대충 읽어보면, 내가 설정한 mock-server 세팅에 관한 내용이다.

 

3. MockServer를 이용한 Parsing 및 Connect 테스트

이제 mock-server 설정도 모두 마쳤으니, 테스트할 크롤링 코드를 적어준다.  mock-server의 request와 response를 지정해주는 아까 구현한 createPathNoteServer() 메서드를 호출한다. (이때 인자값은 response 값을 의미하도록 구현했다.)

이후 내가 설정한 mock-server의 주소값 포트 Url에 맞게, Crawling한 결과를 가져오는 코드를 넣어주고, 그 값을 테스트한다.

[GET] http://localhost:9000/movie/sdb/rank/rmovie.nhn
host localhost
port 9000
path /movie/sdb/rank/rmovie.nhn

URL을 적어줄 때 http를 빼고 적어주면 인식하지 못한다! 주의하자

 // NaverMovieCrawlerTest.java
 
    @BeforeEach
    void setUp() {
        mockServer = ClientAndServer.startClientAndServer(PORT);
        System.out.println("mock server start");
    }

    @Test
    public void naverMovieMockServerTest(){

        createNaverRankingPageServer("ranking_naver_movie.html");
        Document document = new NaverMovieCrawler().getCrawlingResult("http://localhost:9000/movie/sdb/rank/rmovie.nhn");
        TopMovieList topMovieList = new TopMovieList(document);
        assertThat(topMovieList.getTopMovies().size()).isEqualTo(50);

    }

    @AfterEach
    void tearDown() {
        mockServer.stop();
        System.out.println("mock server stop");
    }

이렇게 코드를 완성해서 실행한 결과 크롤링한 데이터가 잘 들어와서 Parsing까지 완료되었음을 테스트 성공 표시를 통해 확인할 수 있다.

 

더보기

4. 크롤링 Parsing 테스트

크롤링 Parsing 테스트의 내용은 코드를 구현한 github에서 Movie.test / TopMovieList.test 파일을 이용해서, 크롤링 로직이 데이터를 내가원하는대로 객체에 바인딩 하는 지 여부를 확인하였다.

그러나 읽어보면 어렵지 않은 코드라서 생략하였다. github 를 참고하자 :)

 

[관련 블로그]

javabom 스터디를 통해서 쓰게된 글이며, 팀 블로그가 존재한다 :) 같은 내용의 글을 업로드 한 상태이다.

https://javabom.tistory.com/

 

자바봄

자바 스프링 블로그 입니다.

javabom.tistory.com

 

[참고 페이지]

https://www.programcreek.com/java-api-examples/?class=org.mockserver.integration.ClientAndServer&method=startClientAndServer

https://www.baeldung.com/mockserver