Skip to content

Commit

Permalink
Add experimental support for CSV and TSV parsing
Browse files Browse the repository at this point in the history
Related to #5
  • Loading branch information
s3cur3 committed Jun 28, 2024
1 parent 371143a commit cc330fc
Show file tree
Hide file tree
Showing 9 changed files with 200 additions and 73 deletions.
4 changes: 3 additions & 1 deletion .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@
#
# More info in the Dialyxir README:
# https://github.com/jeremyjh/dialyxir#elixir-term-format
[]
[
{"deps/nimble_csv/lib/nimble_csv.ex", :unmatched_return}
]
36 changes: 19 additions & 17 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@

## v0.1.0

Renamed to `ParameterizedTest`, with the accompanying macro `param_test`.
(Why not `parameterized_test`? It's longer, harder to spell, and there are a lot of
other accepted spellings, including "parameterised," "parametrized," and "parametrised.")
- Renamed to `ParameterizedTest`, with the accompanying macro `param_test`.
(Why not `parameterized_test`? It's longer, harder to spell, and there are a lot of
other accepted spellings, including "parameterised," "parametrized," and "parametrised.")
- Added support for hand-rolled lists of parameters, like:

Added support for hand-rolled lists of parameters, like:

```elixir
param_test "shipping policy matches the web site",
[
# Items in the parameters list can be either maps...
%{spending_by_category: %{pants: 29_99}, coupon: "FREE_SHIP"},
# ...or keyword lists
[spending_by_category: %{shoes: 19_99, pants: 29_99}, coupon: nil]
],
%{spending_by_category: spending_by_category, coupon: coupon} do
...
end
```
```elixir
param_test "shipping policy matches the web site",
[
# Items in the parameters list can be either maps...
%{spending_by_category: %{pants: 29_99}, coupon: "FREE_SHIP"},
# ...or keyword lists
[spending_by_category: %{shoes: 19_99, pants: 29_99}, coupon: nil]
],
%{spending_by_category: spending_by_category, coupon: coupon} do
...
end
```
- Added experimental support for populating test parameters from CSV and TSV files.
Eventually I'd like to extend this to other sources like Notion documents.
(Feedback welcome—just open an issue!)
## v0.0.1
Expand Down
189 changes: 134 additions & 55 deletions lib/parameterized_test.ex
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
NimbleCSV.define(ParameterizedTest.TsvParser, separator: "\t", escape: "\"")
NimbleCSV.define(ParameterizedTest.CsvParser, separator: ",", escape: "\"")

defmodule ParameterizedTest do
@moduledoc ~S"""
A utility for defining eminently readable parameterized (or example-based) tests.
Expand Down Expand Up @@ -72,9 +75,31 @@ defmodule ParameterizedTest do

escaped_examples =
case examples do
str when is_binary(str) -> str |> parse_examples(context) |> Macro.escape()
list when is_list(list) -> list |> parse_examples(context) |> Macro.escape()
already_escaped when is_tuple(already_escaped) -> already_escaped
str when is_binary(str) ->
file_extension =
str
|> Path.extname()
|> String.downcase()

case file_extension do
ext when ext in [".md", ".markdown", ".csv"] ->
str
|> parse_file_path_examples(context)
|> Macro.escape()

_ ->
str
|> parse_examples(context)
|> Macro.escape()
end

list when is_list(list) ->
list
|> parse_examples(context)
|> Macro.escape()

already_escaped when is_tuple(already_escaped) ->
already_escaped
end

max_describe_length_to_fit_on_one_line = 82
Expand Down Expand Up @@ -104,58 +129,10 @@ defmodule ParameterizedTest do
def parse_examples(table, context \\ [])

def parse_examples(table, context) when is_binary(table) do
rows =
table
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)

case rows do
[header | rows] ->
headers =
header
|> split_cells()
|> Enum.map(&String.to_atom/1)

rows
|> Enum.reject(&separator_row?/1)
|> Enum.map(fn row ->
cells =
row
|> split_cells()
|> Enum.map(fn cell ->
try do
case Code.eval_string(cell) do
{val, []} -> val
_ -> raise "Failed to evaluate example cell `#{cell}` in row `#{row}`"
end
rescue
e ->
reraise "Failed to evaluate example cell `#{cell}` in row `#{row}`. #{inspect(e)}", __STACKTRACE__
end
end)

if length(cells) != length(headers) do
raise """
The number of cells in each row must exactly match the
number of headers on your example table.
Problem row#{file_meta(context)}:
#{row}
Expected headers:
#{inspect(headers)}
"""
end

headers
|> Enum.zip(cells)
|> Map.new()
end)
|> Enum.reject(&(&1 == %{}))

[] ->
[]
end
table
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> parse_md_rows(context)
end

# This function head handles a list of already-parsed examples, like:
Expand All @@ -166,6 +143,14 @@ defmodule ParameterizedTest do
# ], %{int_1: int_1, int_2: int_2} do
def parse_examples(table, context) when is_list(table) do
{evaled_table, _, _} = Code.eval_quoted_with_env(table, [], __ENV__)

case evaled_table do
str when is_binary(str) -> parse_file_path_examples(str, context)
list when is_list(list) -> parse_hand_rolled_table(list, context)
end
end

defp parse_hand_rolled_table(evaled_table, context) do
parsed_table = Enum.map(evaled_table, &Map.new/1)

keys = MapSet.new(parsed_table, &Map.keys/1)
Expand All @@ -182,6 +167,100 @@ defmodule ParameterizedTest do
parsed_table
end

defp parse_file_path_examples(path, context) do
file = File.read!(path)

case path |> Path.extname() |> String.downcase() do
md when md in [".md", ".markdown"] -> parse_examples(file, context)
".csv" -> parse_csv_file(file, context)
".tsv" -> parse_tsv_file(file, context)
_ -> raise "Unsupported file extension for parameterized tests #{path} #{file_meta(context)}"
end
end

defp parse_csv_file(file, context) do
file
|> ParameterizedTest.CsvParser.parse_string(skip_headers: false)
|> parse_csv_rows(context)
end

defp parse_tsv_file(file, context) do
file
|> ParameterizedTest.TsvParser.parse_string()
|> Enum.map(&String.trim/1)
|> parse_csv_rows(context)
end

defp parse_md_rows(rows, context)
defp parse_md_rows([], _context), do: []

defp parse_md_rows([header | rows], context) do
headers =
header
|> split_cells()
|> Enum.map(&String.to_atom/1)

rows
|> Enum.reject(&separator_row?/1)
|> Enum.map(fn row ->
cells =
row
|> split_cells()
|> Enum.map(&eval_cell(&1, row, context))

check_cell_count(cells, headers, row, context)

headers
|> Enum.zip(cells)
|> Map.new()
end)
|> Enum.reject(&(&1 == %{}))
end

defp parse_csv_rows(rows, context)
defp parse_csv_rows([], _context), do: []

defp parse_csv_rows([header | rows], context) do
headers = Enum.map(header, &String.to_atom/1)

rows
|> Enum.map(fn row ->
cells = Enum.map(row, &eval_cell(&1, row, context))

check_cell_count(cells, headers, row, context)

headers
|> Enum.zip(cells)
|> Map.new()
end)
|> Enum.reject(&(&1 == %{}))
end

defp eval_cell(cell, row, _context) do
case Code.eval_string(cell) do
{val, []} -> val
_ -> raise "Failed to evaluate example cell `#{cell}` in row `#{row}`}"
end
rescue
e ->
reraise "Failed to evaluate example cell `#{cell}` in row `#{row}`. #{inspect(e)}", __STACKTRACE__
end

defp check_cell_count(cells, headers, row, context) do
if length(cells) != length(headers) do
raise """
The number of cells in each row must exactly match the
number of headers on your example table.
Problem row#{file_meta(context)}:
#{row}
Expected headers:
#{inspect(headers)}
"""
end
end

defp split_cells(row) do
row
|> String.split("|", trim: true)
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ defmodule ParameterizedTest.MixProject do
defp deps do
List.flatten(
[
{:nimble_csv, "~> 1.1"},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},

# Code quality
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
"nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"},
}
3 changes: 3 additions & 0 deletions test/fixtures/params.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
coupon,gets_free_shipping?
,false
"""FREE_SHIP""",true
6 changes: 6 additions & 0 deletions test/fixtures/params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
| spending_by_category | coupon | gets_free_shipping? |
|-------------------------------|-------------|---------------------|
| %{shoes: 19_99, pants: 29_99} | | false |
| %{shoes: 59_99, pants: 49_99} | | true |
| %{socks: 10_99} | | true |
| %{shoes: 19_99} | "FREE_SHIP" | true |
3 changes: 3 additions & 0 deletions test/fixtures/params.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
coupon gets_free_shipping?
false
"""FREE_SHIP""" true
30 changes: 30 additions & 0 deletions test/parameterized_test_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,36 @@ defmodule ParameterizedTestTest do
free_shipping? = shipping_cost == 0
assert free_shipping? == gets_free_shipping?
end

param_test "supports Markdown files as input",
"test/fixtures/params.md",
%{
spending_by_category: spending_by_category,
coupon: coupon,
gets_free_shipping?: gets_free_shipping?
} do
shipping_cost = ExampleShippingCalculator.calculate_shipping(spending_by_category, coupon)
free_shipping? = shipping_cost == 0
assert free_shipping? == gets_free_shipping?
end

param_test "supports CSV files as input",
"test/fixtures/params.csv",
%{
coupon: coupon,
gets_free_shipping?: gets_free_shipping?
} do
assert (coupon == "FREE_SHIP" and gets_free_shipping?) or (is_nil(coupon) and not gets_free_shipping?)
end

param_test "supports TSV files as input",
"test/fixtures/params.csv",
%{
coupon: coupon,
gets_free_shipping?: gets_free_shipping?
} do
assert (coupon == "FREE_SHIP" and gets_free_shipping?) or (is_nil(coupon) and not gets_free_shipping?)
end
end

defmodule ExampleAccounts do
Expand Down

0 comments on commit cc330fc

Please sign in to comment.