# 実践PBT輪読会 ## 第5章 信頼できるテスト (3) 2024/10/15 [@kdnakt](https://twitter.com/kdnakt) --- ## 前回の範囲 ### 第5章 信頼できるテスト - 5.4 レコードのフィルタリング https://hackmd.io/@kdnakt/Hy6uCaSTR#/ --- ## 今回の範囲 ### 第5章 信頼できるテスト - 5.5 従業員モジュール - 5.6 テンプレート - 5.7 すべてをつなぎ合わせよう - 5.8 まとめ --- ### 5.5 従業員モジュール - 従業員モジュールにまとめる - 5.3 CSVのパース - 5.4 レコードのフィルタリング - 煩わしい業務ルールだらけの連携作業 ---- #### 5.5.1 要求の設定 - CSVの仕様は本当に緩い - 私たちの責任:文字列から適切な型への 変換・検証 - 例)"YYYY/MM/DD"をDate構造体に変換 - 以下の機能を定義 - 各フィールドへのアクセサー - 従業員を誕生日で検索する関数 - CSV文字列を受け取り各従業員のマップのセットを返す関数 ---- #### 5.5.2 CSVデータを適合させる ```csvpreview last_name, first_name, date_of_birth, email ``` - CSVパーサーの出力結果の先頭には 余計な空白がある - 「空白で始まるフィールドがない」ことを 確認するプロパティを実装する ---- #### プロパティ本体 ```elixir property "先頭の空白が修正されたか確認" do forall map <- raw_employee_map() do # 実装予定の関数 emp = Employee.adapt_csv_result_shim(map) # 実装予定の関数 strs = Enum.filter( Map.keys(emp) ++ Map.values(emp), &is_binary/1 ) Enum.all?(strs, fn s -> String.first(s) != " " end) end end ``` ---- #### ジェネレータ ```elixir defp raw_employee_map() do let proplist <- [ {"last_name", CsvTest.field()}, # 前々回実装した関数 {" first_name", whitespaced_text()}, # 次のページ {" date_of_birth", text_date()}, # 次のページ {" email", whitespaced_text()} # 次のページ ] do Map.new(proplist) end end ``` ---- #### ヘルパー ```elixir defp whitespaced_text() do let(txt <- CsvTest.field(), do: " " <> txt) end ``` ```elixir defp text_date() do rawdate = {choose(1900, 2020), choose(1, 12), choose(1, 31)} date = such_that( {y, m, d} <- rawdate, when: {:error, :invalid_date} != Date.new(y, m, d) ) let {y, m, d} <- date do IO.chardata_to_string(:io_lib.format( " ~w/~2..0w/~2..0w", [y, m, d] )) end end ``` ---- #### Employee.adapt_csv_result_shim/1 - テスト専用のインターフェースとして定義する ```elixir # lib/employee.ex defmodule Bday.Employee do if Mix.env() == :test do def adapt_csv_result_shim(map), do: adapt_csv_result(map) end defp adapt_csv_result(map) do # 後述 end end ``` ---- #### CSV文字列から型への変換 ```elixir # lib/employee.ex @opaque employee() :: %{required(String.t()) => term()} # opaque型として隠蔽し、将来の柔軟性を担保 @opaque handle() :: {:raw, [employee()]} def from_csv(string) do {:raw, for map <- Bday.Csv.decode(string) do adapt_csv_result(map) # 次ページ end} end ``` ---- #### マップを適切に変換する ```elixir # lib/employee.ex defp adapt_csv_result(map) do for {k, v} <- map, into: %{} do {trim(k), maybe_null(trim(v))} end end # 先頭の空白を取り除く defp trim(str), do: String.trim_leading(str, " ") # 空文字列でテストが失敗するので回避 defp maybe_null(""), do: nil defp maybe_null(str), do: str ``` ---- #### 日付のフォーマット確認 ```elixir property "日付が正しくフォーマットされたか確認" do forall map <- raw_employee_map() do case Employee.adapt_csv_result_shim(map) do %{"date_of_birth" => %Date{}} -> true _ -> false end end end ``` ---- #### 日付のフォーマット変換 ```elixir defp adapt_csv_result(map) do map = for {k, v} <- map, into: %{} do {trim(k), maybe_null(trim(v))} end # 有効な日付フォーマットか確認 dob = Map.fetch!(map, "date_of_birth") %{map | "date_of_birth" => parse_date(dob)} end ``` ```elixir defp parse_date(str) do [y, m, d] = Enum.map( String.split(str, "/"), &String.to_integer(&1) ) {:ok, date} = Date.new(y, m, d) date end ``` ---- #### 5.5.3 従業員を使う - データアクセスとビジネスロジックを分離 - まずはアクセサーの実装 ```elixir! @spec last_name(employee()) :: String.t() | nil def last_name(%{"last_name" => name}), do: name @spec first_name(employee()) :: String.t() | nil def first_name(%{"first_name" => name}), do: name @spec date_of_birth(employee()) :: Date.t() def date_of_birth(%{"date_of_birth" => dob}), do: dob @spec email(employee()) :: String.t() def email(%{"email" => email}), do: email ``` ---- #### 将来の拡張に備える ```elixir! # opaque型をemployee型に変換する @spec fetch(handle()) :: [employee()] def fetch({:raw, maps}), do: maps @spec filter_birthday(handle(), Date.t()) :: handle() def filter_birthday({:raw, employees}, date) do {:raw, Bday.Filter.birthday(employees, date)} end # CSVからDBに移行する場合 # @opaque handle() :: {:raw, [employee()]} # ↓ # @opaque handle() :: {db, Config, Connection, SQLQuery} # としてSQL実行する処理をfetch/1に実装する ``` ---- #### 全体を1つのプロパティにまとめる ```elixir property " ハンドラーからのアクセスを確認 " do forall maps <- non_empty(list(raw_employee_map())) do handle = maps |> Csv.encode() |> Employee.from_csv() partial = Employee.filter_birthday(handle, ~D[1900-01-01]) list = Employee.fetch(partial) ``` ```elixir # クラッシュしないか確認 for x <- list do Employee.first_name(x) Employee.last_name(x) Employee.email(x) Employee.date_of_birth(x) end true end end ``` --- ### 5.6 テンプレート - メールの送信はユニットテストの範囲外 - メールのテンプレートのみテストする ---- #### テンプレートのプロパティ ```elixir! defmodule MailTplTest do use ExUnit.Case use PropCheck alias Bday.MailTpl, as: MailTpl property "メールテンプレートは名前を持っている" do forall employee <- employee_map() do String.contains?( MailTpl.body(employee), Map.fetch!(employee, "first_name") ) end end # employee_map()ジェネレータは省略 end ``` ---- #### テンプレートの実装 ```elixir! defmodule Bday.MailTpl do def body(employee) do name = Bday.Employee.first_name(employee) "Happy birthday, dear #{name}!" end def full(employee) do {[Bday.Employee.email(employee)], "Happy birthday!", body(employee)} end end ``` --- ### 5.7 すべてをつなぎ合わせよう - 注意:あくまでユニットテストとして繋ぎ合わせる ---- #### lib/bday.ex - [全体設計](https://hackmd.io/@kdnakt/r1XQs83hC#/4/2) ```elixir! defmodule Bday do def run(path) do today = DateTime.to_date(DateTime.utc_now()) # 今日の日付の取得 set = path |> File.read!() # ファイルの読込 |> Bday.Employee.from_csv() # CSVを従業員名簿に変換 # 日付から従業員を検索 |> Bday.Employee.filter_birthday(today) |> Bday.Employee.fetch() ``` ```elixir for employee <- set do employee |> Bday.MailTpl.full() # メールを文字列に整形 |> send_email() # メールを送信 end :ok end end ``` ---- #### メール送信はしない ```elixir! defp send_email({to, _topic, _body}) do IO.puts("sent birthday email to #{to}") # メール送信の代わり end ``` ---- #### プログラムを実行する ```shell! $ cat db.csv last_name, first_name, date_of_birth, email Doe, John, 1982/10/08, john.doe@foobar.com Ann, Mary, 1975/09/11, mary.ann@foobar.com Robert, Joe, 2024/10/15, born.today@example.com $ mix run -e 'Bday.run("db.csv")' send birthday email to born.today@example.com ``` --- ### 5.8 まとめ - 事例ベースのテストとプロパティのバランスが大事 - 事例ベースのテストでプロパティより多くのケースを網羅 - コード全体は以下のリポジトリに https://github.com/kdnakt/pbt-elixir/tree/main/bday
{"title":"実践PBT輪読会 第5章 信頼できるテスト (3)","slideOptions":"{\"transition\":\"slide\"}","description":"2024/10/15 @kdnakt","contributors":"[{\"id\":\"df36d0f0-b67e-41ac-96b3-f3988326d230\",\"add\":7232,\"del\":500}]"}
    153 views