Java8 - 람다 테스팅, 디버깅


이 포스트에서는 람다 표현식과 스트림 API 를 사용하는 코드를 테스트하고 디버깅하는 방법에 대해 알아본다.

소스는 github 에 있습니다.


목차


1. 람다 테스팅

1.1. 일반적인 단위 테스트

아래는 Point 클래스가 있고, 기능이 의도대로 동작하는 확인하는 단위 테스트이다.

public class Point {
  private final int x;
  private final int y;

  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int getX() {
    return x;
  }

  public int getY() {
    return y;
  }

  public Point moveRightBy(int x) {
    return new Point(this.x + x, this.y);
    }
}

단위 테스트 코드

import org.junit.*;

import static org.junit.Assert.assertEquals;

public class Testing {

  @Test
  public void testMoveRightBy() {
    Point p1 = new Point(5,5);
    Point p2 = p1.moveRightBy(10);

    assertEquals(15, p2.getX());
    assertEquals(5, p2.getY());
  }
}

@Test 사용 시 junit dependency 를 추가해주어야 한다.

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
</dependency>

1.2. 람다 표현식의 동작 테스트

위의 moveRightBy() 메서드는 public 이기 때문에 문제없이 동작하지만 람다는 익명(=익명 함수) 이므로 테스트 코드 이름을 호출할 수 없다.
따라서 필드에 람다를 저장해서 재사용하는 방법으로 람다 로직 테스트가 가능하다.

예를 들어 compareByXAndThenY 라는 정적 필드를 추가하고, 여기에 람다 표현식을 저장한다.

public final static Comparator<Point> compareByXAndThenY =
          Comparator.comparing(Point::getX).thenComparing(Point::getY);

thenComparing() 를 이용하여 첫 번째 비교자가 같을 때 두 번째 비교자에 객체를 전달하여 비교할 수 있다.

람다 표현식을 함수형 인터페이스의 인스턴스를 생성하기 때문에 생성된 인스턴스의 동작으로 람다 표현식을 테스트할 수 있다.

@Test
public void testComparingTowPoints() {
  Point p1 = new Point(10, 5);
  Point p2 = new Point(5, 10);

  int result = Point.compareByXAndThenY.compare(p1, p2); // 1

  // compare(): 첫 번째 인자가 작으면 -1, 같으면 0, 첫 번째 인자가 더 크면 1

  assertEquals(1, result);
}

1.3. 람다를 사용하는 메서드의 동작을 테스트

람다의 목표는 정해진 동작을 다른 메서드에서 사용할 수 있도록 하나의 조각으로 캡슐화하는 것이므로 세부 구현을 포함하는 람다 표현식은 공개하지 않아야 한다.
람다 표현식을 사용하는 메서드의 동작을 테스트함으로써 람다를 공개하지 않으면서도 람다 표현식을 검증할 수 있다.

public static List<Point> moveAllPointsRightBy(List<Point> point, int x) {
  return point.stream()
          .map(p -> new Point(p.getX()+x, p.getY()))
          .collect(Collectors.toList());
}

이제 moveAllPointsRightBy() 메서드의 동작을 확인함으로써 p -> new Point(p.getX()+x, p.getY()) 를 테스트할 수 있다.

@Test
public void testMoveAllPointsRightBy() {
  List<Point> points = Arrays.asList(new Point(5,5), new Point(10,5));
  List<Point> expectedPoints = Arrays.asList(new Point(15, 5), new Point(20,5));

  List<Point> newPoints = Point.moveAllPointsRightBy(points, 10);

  assertEquals(expectedPoints, newPoints);
}
java.lang.AssertionError: 
Expected :[com.assu.study.mejava8.chap08.Point@233c0b17, com.assu.study.mejava8.chap08.Point@63d4e2ba]
Actual   :[com.assu.study.mejava8.chap08.Point@7bb11784, com.assu.study.mejava8.chap08.Point@33a10788]

두 객체를 값이 아닌 주소로 비교하기 때문에 위와 같은 오류가 뜬다.

두 객체를 값으로 비교하기 위해 Point 클래스에 equals()hashCode() 를 오버라이딩 해주면 두 객체가 같은 객체(값으로 비교)로 판별되어 Test pass 된다.

@Override
public boolean equals(Object o) {
  if (this == o) return true;
  if (o == null || getClass() != o.getClass()) return false;
  Point point = (Point) o;
  return getX() == point.getX() && getY() == point.getY();
}

@Override
public int hashCode() {
  return Objects.hash(getX(), getY());
}

2. 디버깅

디버깅 시 스택 스페이스와 로그를 먼저 확인하게 되는데 람다 표현식과 스트림은 사실 디버깅이 어려운 편이다.


2.1. 스택 트레이스 확인

아래와 같은 Point 클래스가 있고, 고의적으로 문제를 일으켜보자.

private static class Point {
  private int x;
  private int y;

  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int getX() {
    return x;
  }

  public void setX(int x) {
    this.x = x;
  }
}
public class Debugging {
  public static void main(String[] args) {
    List<Point> points = Arrays.asList(new Point(3, 2), null);
    points.stream().map(p -> p.getX()).forEach(System.out::println);
  }
}
3
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "com.assu.study.mejava8.chap08.Debugging$Point.getX()" because "p" is null
	at com.assu.study.mejava8.chap08.Debugging.lambda$main$0(Debugging.java:9)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:992)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:150)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at com.assu.study.mejava8.chap08.Debugging.main(Debugging.java:9)

Process finished with exit code 1

의도한대로 points 리스트의 두 번째 인수가 null 이기 때문에 스트림 파이프라인에서 에러가 발생하여 스트림 파이프라인 작업과 관련된 전체 메서드 호출 리스트가 출력되었다.

at com.assu.study.mejava8.chap08.Debugging.lambda$main$0(Debugging.java:9)

이런 코드는 람다 표현식 내부에서 에러가 발생했음을 가리킨다.
람다 표현식은 이름이 없기 때문에 컴파일러가 람다를 참조하는 이름을 만들어 낸 것이다. (lambda$main$0)

메서드 레퍼런스를 사용해도 스택 트레이스에 메서드명이 나오지는 않는다.

위 코드에서 람다 표현식 p -> p.getX() 를 Point::getX() 로 변경 후 로그는 아래와 같다.

points.stream().map(Point::getX).forEach(System.out::println);
3
Exception in thread "main" java.lang.NullPointerException
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:992)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:150)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at com.assu.study.mejava8.chap08.Debugging.main(Debugging.java:10)

Process finished with exit code 1

단, 메서드 레퍼런스를 사용하는 클래스와 같은 곳에 선언되어 있는 메서드를 참조할 때는 메서드 레퍼런스의 이름이 스택 트레이스에 나타난다.

List<Integer> numbers = Arrays.asList(1,2,3);
numbers.stream().map(Debugging::divideByZero).forEach(System.out::println);
Exception in thread "main" java.lang.ArithmeticException: / by zero
	at com.assu.study.mejava8.chap08.Debugging.divideByZero(Debugging.java:17)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:992)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:150)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at com.assu.study.mejava8.chap08.Debugging.main(Debugging.java:13)

Process finished with exit code 1

2.2. 로깅: peek()

List<Integer> numbers = Arrays.asList(2,3,4,5);

// 20 22
numbers.stream()
        .map(x -> x + 17)
        .filter(x -> x%2 == 0)
        .limit(3)
        .forEach(System.out::println);

위 코드의 결과는 아래와 같다.

20
22

forEach 를 호출하는 순간 전체 스트림이 소비된다.
peek() 을 활용하여 스트림 파이프라인에 적용된 각각의 연산인 map, filter, limit 이 어떤 결과를 도출하는지 확인해보자.

peek() 은 스트림의 각 요소를 소비한 것처럼 동작하지만 실제로 스트림의 요소를 소비하지는 않는다.
peek() 은 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달한다.

List<Integer> result = numbers.stream()
        .peek(x -> System.out.println("from stream: " + x)) // 소스에서 처음 소비한 요소 출력
        .map(x -> x + 17)
        .peek(x -> System.out.println("after map: " + x)) // map() 실행 결과 출력
        .filter(x -> x%2 == 0)
        .peek(x -> System.out.println("after filter: " + x))  // filter() 실행 결과 출력
        .limit(3)
        .peek(x -> System.out.println("after limit: " + x)) // limit() 실행 결과 출력
        .collect(Collectors.toList());

System.out.println(result); // [20, 22]
from stream: 2
after map: 19
from stream: 3
after map: 20
after filter: 20
after limit: 20
from stream: 4
after map: 21
from stream: 5
after map: 22
after filter: 22
after limit: 22

3. 정리하며..

  • 람다 표현식으로 가독성이 좋고 더 유연한 코드를 만들 수 있음
  • 익명 클래스는 람다 표현식으로 바꾸는 것이 좋음
    단, 이 때 this, 변수 섀도 등 미묘하게 의미상 다른 내용이 있음을 주의해야 함
    메서드 레퍼런스로 람다 표현식보다 더 가독성이 좋은 코드 구현 가능
  • 반복적으로 컬렉션을 처리하는 루틴은 스트림 API 로 대체 가능한지 고려
  • 람다 표현식도 단위 테스트 수행 가능
    단, 람다 표현식 자체를 테스트하는 것보다는 람다 표현식이 사용되는 메서드의 동작을 테스트하는 것이 바람직함
  • 람다 표현식을 사용하면 스택 트레이스를 이해하기 어려워짐
  • 스트림 파이프라인에서 요소를 처리할 때 peek() 메서드로 중간값 확인 가능

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트 저자의 Java 8 in Action을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10