# 実践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}]"}