Do you want to pick up from where you left of?
Take me there

StreamData

ExUnit과 같은 예제 기반 테스트는 코드가 생각한 대로 동작하는지 확인하는 훌륭한 도구입니다. 그러나 예제 기반 테스트에는 몇 가지 단점이 있습니다.

  • 몇 가지 입력만 테스트하기 때문에 엣지 케이스를 놓치기 쉽습니다.
  • 요구사항을 충분히 고려하지 않고 테스트를 작성할 수 있습니다.
  • 하나의 함수에 대해 여러 예제를 사용하는 경우 테스트가 장황해질 수 있습니다.

이 단원에서는 StreamData가 이러한 단점을 어떻게 극복하는지 알아보겠습니다.

StreamData가 무엇입니까?

StreamData는 상태를 가지지 않는 속성 기반 테스트 라이브러리입니다.

StreamData는 기본적으로 매번 랜덤 데이터를 사용하여 각 테스트를 100회 실행합니다. 테스트가 실패하면 StreamData는 입력값을 테스트가 실패한 가장 작은 값으로 축소합니다. 이는 코드를 디버깅할 때 도움이 됩니다! 만약 50개의 항목이 있는 리스트로부터 함수가 종료되고 그 원인이 하나의 항목일 때 StreamData는 문제가 되는 항목을 찾는데 도움이 될 수 있습니다.

이 테스트 라이브러리에는 두 개의 주요 모듈이 있습니다. StreamData는 랜덤 데이터 스트림을 생성합니다. ExUnitProperties는 생성된 데이터를 입력으로 사용하여 함수에 대해 테스트를 수행하도록 합니다.

입력을 모르면서 어떻게 함수를 의미 있게 테스트할 수 있는지 궁금할 것입니다. 읽어보십시오!

StreamData 설치

첫째, 새로운 Mix 프로젝트를 만듭니다. 만약 도움이 필요하다면 New Projects를 참조하십시오.

둘째, StreamData를 mix.exs 파일의 의존성으로 추가합니다:

defp deps do
  [{:stream_data, "~> x.y", only: :test}]
end

라이브러리의 설치 지침에 있는 버전으로 xy를 바꿔줍니다.

셋째, 터미널에서 이 커맨드 라인을 실행하세요:

mix deps.get

StreamData 사용

StreamData의 특징을 설명하기 위해, 값을 반복하는 몇 가지 간단한 유틸리티 함수를 작성할 것입니다. 우리가 String.duplicate/2와 같은 함수를 원하지만, 문자열, 리스트, 튜플을 복제하는 함수를 원한다고 해봅시다.

Strings

먼저 문자열을 반복하는 함수를 작성해 봅시다. 함수에 대한 요구사항은 무엇일까요?

  1. 첫 번째 인자는 문자열이어야 합니다. 우리가 복제할 문자열입니다.
  2. 두 번째 인자는 음이 아닌 정수여야 합니다. 첫 번째 인자를 얼마나 반복할지 나타냅니다.
  3. 함수는 문자열을 반환해야 합니다. 새 문자열은 원래 문자열을 단지 0회 이상 반복한 것입니다.
  4. 원래 문자열이 비어있다면, 반환되는 문자열 또한 비어있어야 합니다.
  5. 두 번째 인자가 0이면, 반환되는 문자열은 비어있어야 합니다.

이 함수를 실행하면, 이렇게 보입니다.

Repeater.duplicate("a", 4)
# "aaaa"

엘릭서는 이를 수행할 수 있는 String.duplicate/2 함수가 있습니다. 새로운 duplicate/2 함수는 단지 그 함수로 위임할 것입니다:

defmodule Repeater do
  def duplicate(string, times) when is_binary(string) do
    String.duplicate(string, times)
  end
end

성공 경로는 ExUnit를 가지고 테스트하기 쉬워야 합니다.

defmodule RepeaterTest do
  use ExUnit.Case

  describe "duplicate/2" do
    test "creates a new string, with the first argument duplicated a specified number of times" do
      assert "aaaa" == Repeater.duplicate("a", 4)
    end
  end
end

하지만 그것은 종합적인 테스트가 아닙니다.
두 번째 인자가 0일 때는 어떻게 해야 할까요? 첫 번째 인자가 빈 문자열일 경우 출력은 어떻게 해야 하나요? 빈 문자열을 반복한다는 것은 무슨 의미일까요? 이 함수는 UTF-8 문자와 어떻게 동작해야 하나요? 이 함수는 큰 문자열 입력에 동작하나요?

엣지 케이스와 큰 문자열을 테스트하기 위해 더 많은 예제를 작성할 수 있습니다. 그러나 StreamData를 사용하여 많은 코드 없이 이 함수를 엄격하게 테스트할 수 있는지 확인해 보겠습니다.

defmodule RepeaterTest do
  use ExUnit.Case
  use ExUnitProperties

  describe "duplicate/2" do
    property "첫 번째 인자를 지정된 수만큼 복제된 새 문자열을 만듭니다" do
      check all str <- string(:printable),
                times <- integer(),
                times >= 0 do

        assert ??? == Repeater.duplicate(str, times)
      end
    end
  end
end

무엇에 쓰이는 건가요?

???는 제가 덧붙인 슈도코드일 뿐입니다. 정확히 무엇을 검증해야 할까요? 다음과 같이 쓸 있습니다.

assert String.duplicate(str, times) == Repeater.duplicate(str, times)

…하지만 그것은 실제 함수의 구현을 이용하는 것일 뿐, 도움이 되지 않습니다. 문자열의 길이를 비교하여 검증을 느슨하게 할 수 있습니다.

expected_length = String.length(str) * times
actual_length =
  str
  |> Repeater.duplicate(times)
  |> String.length()

assert actual_length == expected_length

나아지긴 했지만, 이상적이진 않습니다. 이 함수가 생성한 임의의 문자열이 길이만 일치한다면 테스트는 통과할 것입니다.

우리는 정말로 두 가지를 확인하고 싶습니다.

  1. 우리의 함수가 생성한 문자열의 길이가 올바른지.
  2. 최종 문자열의 내용은 반복되는 원래 문자열인지.

이것은 단지 속성을 다시 표현하는 또 다른 방법입니다. 우리는 이미 #1을 검증할 코드가 있습니다. #2를 검증하기 위해 최종 문자열을 원래 문자열로 나누고, 0개 이상의 빈 문자열이 남아 있는지 확인합니다.

list =
  str
  |> Repeater.duplicate(times)
  |> String.split(str)

assert Enum.all?(list, &(&1 == ""))

우리의 주장을 종합해 봅시다.

defmodule RepeaterTest do
  use ExUnit.Case
  use ExUnitProperties

  describe "duplicate/2" do
    property "첫 번째 인자를 지정된 수만큼 복제된 새 문자열을 만듭니다" do
      check all str <- string(:printable),
                times <- integer(),
                times >= 0 do
        new_string = Repeater.duplicate(str, times)

        assert String.length(new_string) == String.length(str) * times
        assert Enum.all?(String.split(new_string, str), &(&1 == ""))
      end
    end
  end
end

원래 테스트와 비교하면 StreamData 버전이 두 배나 길다는 것을 알 수 있습니다. 그러나, 원래 테스트에 더 많은 테스트 케이스를 추가할 때쯤이면…

defmodule RepeaterTest do
  use ExUnit.Case

  describe "문자열 복제하기" do
    test "첫 번째 인자를 두 번째 인자만큼 복제한다" do
      assert "aaaa" == Repeater.duplicate("a", 4)
    end

    test "첫 번째 인자가 빈 문자열인 경우 빈 문자열을 반환합니다" do
      assert "" == Repeater.duplicate("", 4)
    end

    test "두 번째 인자가 0인 경우 빈 문자열을 반환합니다" do
      assert "" == Repeater.duplicate("a", 0)
    end

    test "긴 문자열도 동작합니다" do
      alphabet = "abcdefghijklmnopqrstuvwxyz"

      assert "#{alphabet}#{alphabet}" == Repeater.duplicate(alphabet, 2)
    end
  end
end

StreamData 버전이 실제로 더 짧습니다. 또한 StreamData는 개발자가 테스트하는 것을 잊어버릴 수 있는 엣지 케이스 테스트도 포함합니다.

Lists

이제 리스트를 반복하는 함수를 작성해 봅시다. 우리는 이 함수가 다음과 같이 동작하기를 원합니다.

Repeater.duplicate([1, 2, 3], 3)
# [1, 2, 3, 1, 2, 3, 1, 2, 3]

다음은 올바르지만, 다소 비효율적인 구현입니다.

defmodule Repeater do
  def duplicate(list, 0) when is_list(list) do
    []
  end

  def duplicate(list, times) when is_list(list) do
    list ++ duplicate(list, times - 1)
  end
end

StreamData 테스트는 다음과 같이 쓰일 것입니다.

defmodule RepeaterTest do
  use ExUnit.Case
  use ExUnitProperties

  describe "duplicate/2" do
    property "원본 리스트로 지정된 수만큼 반복되는 새 리스트를 만듭니다" do
      check all list <- list_of(term()),
                times <- integer(),
                times >= 0 do
        new_list = Repeater.duplicate(list, times)

        assert length(new_list) == length(list) * times

        if length(list) > 0 do
          assert Enum.all?(Enum.chunk_every(new_list, length(list)), &(&1 == list))
        end
      end
    end
  end
end

임의의 타입과 임의의 길이를 가지는 리스트를 만들기 위해 StreamData.list_of/1StreamData.term/0을 사용하였습니다.

문자열 반복의 속성기반 테스트와 마찬가지로, 새 리스트의 길이를 원본 리스트의 길이에서 times로 곱한 값과 비교합니다. 두 번째 주장은 몇 가지 설명이 필요합니다.

  1. 새로운 리스트를 여러 리스트로 나누고, 각 리스트는 list와 같은 수의 요소를 가지고 있습니다.
  2. 그다음 각 분할된 리스트가 list와 같은지 확인합니다.

다르게 표현하자면, 원래 리스트가 최종 리스트에 적절한 횟수로 나타나는지 확인하고, 다른 요소들이 최종 목록에 나타나지 않는지 확인합니다.

왜 이런 조건을 두어야 할까요? 첫 번째 주장과 조건부가 결합하여 원래 리스트와 최종 리스트가 모두 비어 있게 되면, 더 이상 리스트를 비교할 필요가 없습니다. 게다가 Enum.chunk_every/2는 두 번째 인자가 양의 정수여야 합니다.

Tuples

마지막으로 튜플의 요소를 반복하는 함수를 구현해봅시다. 함수는 다음과 같이 동작해야 합니다.

Repeater.duplicate({:a, :b, :c}, 3)
# {:a, :b, :c, :a, :b, :c, :a, :b, :c}

우리가 접근할 수 있는 한 가지 방법은 튜플을 리스트로 변환하고 리스트를 복제한 다음 다시 데이터 구조를 튜플로 변환하는 것입니다.

defmodule Repeater do
  def duplicate(tuple, times) when is_tuple(tuple) do
    tuple
    |> Tuple.to_list()
    |> Repeater.duplicate(times)
    |> List.to_tuple()
  end
end

어떻게 테스트할 수 있을까요? 지금까지와는 조금 다르게 접근해 봅시다. 문자열과 리스트의 경우 최종 데이터의 길이와 데이터 내용에 관해 주장했습니다. 튜플도 같은 방식으로 접근할 수 있지만, 테스트 코드가 그렇게 간단하지는 않을 것입니다.

튜플에서 수행할 두 가지 순차적인 작업을 고려해보세요.

  1. 튜플에서 Repeater.duplicate/2를 호출하고 결과를 리스트로 변환합니다.
  2. 튜플을 리스트로 변환하고 리스트를 Repeater.duplicate/2로 전달합니다.

이것은 “다른 경로, 같은 목적지“라고 불리는 Scott Wlaschin의 패턴의 적용입니다. 저는 이 두 가지 작업이 모두 같은 결과를 가져오길 기대합니다. 이 접근법을 우리의 테스트에서 사용해봅시다.

defmodule RepeaterTest do
  use ExUnit.Case
  use ExUnitProperties

  describe "duplicate/2" do
    property "원본 튜플을 지정된 수만큼 반복되는 새 튜플을 만듭니다" do
      check all t <- tuple({term()}),
                times <- integer(),
                times >= 0 do
        result_1 =
          t
          |> Repeater.duplicate(times)
          |> Tuple.to_list()

        result_2 =
          t
          |> Tuple.to_list()
          |> Repeater.duplicate(times)

        assert result_1 == result_2
      end
    end
  end
end

요약

이제 문자열, 리스트, 튜플을 반복하는 세 개의 함수가 있습니다. 우리의 구현의 올바름을 높은 수준으로 확신할 수 있는 몇 가지 속성 기반 테스트가 있습니다.

여기 우리의 최종 애플리케이션 코드입니다.

defmodule Repeater do
  def duplicate(string, times) when is_binary(string) do
    String.duplicate(string, times)
  end

  def duplicate(list, 0) when is_list(list) do
    []
  end

  def duplicate(list, times) when is_list(list) do
    list ++ duplicate(list, times - 1)
  end

  def duplicate(tuple, times) when is_tuple(tuple) do
    tuple
    |> Tuple.to_list()
    |> Repeater.duplicate(times)
    |> List.to_tuple()
  end
end

다음은 속성 기반 테스트들입니다.

defmodule RepeaterTest do
  use ExUnit.Case
  use ExUnitProperties

  describe "duplicate/2" do
    property "첫 번째 인자를 지정된 수만큼 복제된 새 문자열을 만듭니다" do
      check all str <- string(:printable),
                times <- integer(),
                times >= 0 do
        new_string = Repeater.duplicate(str, times)

        assert String.length(new_string) == String.length(str) * times
        assert Enum.all?(String.split(new_string, str), &(&1 == ""))
      end
    end

    property "원본 리스트로 지정된 수만큼 반복되는 새 리스트를 만듭니다" do
      check all list <- list_of(term()),
                times <- integer(),
                times >= 0 do
        new_list = Repeater.duplicate(list, times)

        assert length(new_list) == length(list) * times

        if length(list) > 0 do
          assert Enum.all?(Enum.chunk_every(new_list, length(list)), &(&1 == list))
        end
      end
    end

    property "원본 튜플을 지정된 수만큼 반복되는 새 튜플을 만듭니다" do
      check all t <- tuple({term()}),
                times <- integer(),
                times >= 0 do
        result_1 =
          t
          |> Repeater.duplicate(times)
          |> Tuple.to_list()

        result_2 =
          t
          |> Tuple.to_list()
          |> Repeater.duplicate(times)

        assert result_1 == result_2
      end
    end
  end
end

터미널 커맨드 라인에 입력하여 테스트를 실행할 수 있습니다.

mix test

각 StreamData 테스트는 기본적으로 100번 실행되는 것을 기억하세요. 또한 StreamData의 랜덤 데이터 중 일부는 다른 데이터보다 생성하는 데 오랜 시간이 걸립니다. 누적 효과로 인해 예제 기반 테스트보다 더 느리게 실행될 것입니다.

그런데도 속성 기반 테스트는 예제 기반 테스트를 멋지게 보완했습니다. 그것은 다양한 입력을 다루는 간결한 테스트를 작성할 수 있게 해줍니다. 테스트 실행 간의 상태를 유지할 필요가 없다면, StreamData는 속성 기반 테스트를 작성할 수 있는 멋진 구문을 제공합니다.

강의에 실수가 있거나 기여하고 싶은 부분이 있으신가요? GitHub에서 이 강의를 수정해보세요!