Skip to content

test: refactor test_case #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ locals_without_parens = [field: 2, field: 3, parameter: 1, plugin: 1, plugin: 2]
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
import_deps: [:ecto],
locals_without_parens: locals_without_parens,
locals_without_parens: [{:assert_type, 2} | locals_without_parens],
export: [
locals_without_parens: locals_without_parens
]
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/elixir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,10 @@ jobs:
run: mix dialyzer --format github
if: ${{ matrix.lint }}

- name: Run tests
run: mix test --cover
if: ${{ matrix.lint }}

- name: Run tests
run: mix test
if: ${{ !matrix.lint }}
8 changes: 8 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ defmodule TypedStructor.MixProject do
links: %{
"GitHub" => @source_url
}
],
test_coverage: [
summary: [threshold: 100],
ignore_modules: [
TypedStructor.Definition,
TypedStructor.GuideCase,
TypedStructor.TestCase
]
]
]
end
Expand Down
32 changes: 19 additions & 13 deletions test/config_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule ConfigTest do
@compile {:no_warn_undefined, ConfigTest.Struct}

# disable async for this test for changing the application env
use TypedStructor.TypeCase, async: false
use TypedStructor.TestCase, async: false

defmodule Plugin do
use TypedStructor.Plugin
Expand All @@ -24,25 +26,29 @@ defmodule ConfigTest do
end
end

test "registers plugins from the config" do
@tag :tmp_dir
test "registers plugins from the config", ctx do
set_plugins_config([Plugin, {PluginWithOpts, [foo: :bar]}])

deftmpmodule do
use TypedStructor
plugin_calls =
with_tmpmodule Struct, ctx do
use TypedStructor

Module.register_attribute(__MODULE__, :plugin_calls, accumulate: true)
Module.register_attribute(__MODULE__, :plugin_calls, accumulate: true)

typed_structor do
field :name, String.t()
end
typed_structor do
field :name, String.t()
end

def plugin_calls, do: @plugin_calls
end
def plugin_calls, do: @plugin_calls
after
Struct.plugin_calls()
end

assert [
{PluginWithOpts, [foo: :bar]},
{Plugin, []}
] === TestModule.plugin_calls()
] === plugin_calls
end

test "raises if the plugin is not a module" do
Expand All @@ -51,7 +57,7 @@ defmodule ConfigTest do
assert_raise ArgumentError,
~r/Expected a plugin module or a tuple with a plugin module and its keyword options/,
fn ->
test_module do
defmodule Struct do
use TypedStructor

typed_structor do
Expand All @@ -67,7 +73,7 @@ defmodule ConfigTest do
assert_raise ArgumentError,
~r/Expected a plugin module or a tuple with a plugin module and its keyword options/,
fn ->
test_module do
defmodule Struct do
use TypedStructor

typed_structor do
Expand Down
64 changes: 64 additions & 0 deletions test/doc_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
defmodule DocTest do
use TypedStructor.TestCase, async: true

@tag :tmp_dir
test "typedoc", ctx do
generated_doc =
with_tmpmodule User, ctx do
use TypedStructor

@typedoc "A user struct"
typed_structor do
field :name, String.t()
field :age, integer()
end
after
fetch_doc!(User, {:type, :t, 0})
end

assert "A user struct" === generated_doc
end

@tag :tmp_dir
test "typedoc inside block", ctx do
generated_doc =
with_tmpmodule User, ctx do
use TypedStructor

typed_structor do
@typedoc "A user struct"
field :name, String.t()
field :age, integer()
end
after
fetch_doc!(User, {:type, :t, 0})
end

assert "A user struct" === generated_doc
end

@tag :tmp_dir
test "moduledoc and typedoc inside submodule's block", ctx do
generated_docs =
with_tmpmodule MyModule, ctx do
use TypedStructor

typed_structor module: User do
@moduledoc "A user module"
@typedoc "A user struct"
field :name, String.t()
field :age, integer()
end
after
{
fetch_doc!(MyModule.User, :moduledoc),
fetch_doc!(MyModule.User, {:type, :t, 0})
}
|> tap(fn _ ->
cleanup_modules([MyModule.User], ctx.tmp_dir)
end)
end

assert {"A user module", "A user struct"} === generated_docs
end
end
5 changes: 4 additions & 1 deletion test/support/guide_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ defmodule TypedStructor.GuideCase do
end

def types(bytecode) when is_binary(bytecode) do
TypedStructor.TypeCase.types(bytecode) <> "\n"
bytecode
|> TypedStructor.TestCase.fetch_types!()
|> TypedStructor.TestCase.format_types()
|> Kernel.<>("\n")
end

defp extract_code(file) do
Expand Down
195 changes: 195 additions & 0 deletions test/support/test_case.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
defmodule TypedStructor.TestCase do
@moduledoc false
use ExUnit.CaseTemplate

setup ctx do
if Map.has_key?(ctx, :tmp_dir) do
true = Code.append_path(ctx.tmp_dir)
on_exit(fn -> Code.delete_path(ctx.tmp_dir) end)
end

:ok
end

using do
quote do
import unquote(__MODULE__)
end
end

@doc """
Defines a temporary module with the given `module_name` and executes the code
in the `after` block. The module is removed after the block is executed.
And the `after` block's return value is returned.

Note that the `module_name` is expanded to the caller's module.
"""
defmacro with_tmpmodule(module_name, ctx, options) when is_list(options) do
module_name =
module_name
|> Macro.expand(__CALLER__)
|> then(&Module.concat(__CALLER__.module, &1))

code = Keyword.fetch(options, :do)

content =
"""
defmodule #{Atom.to_string(module_name)} do
#{Macro.to_string(code)}
end
"""

fun =
quote do
fn ->
alias unquote(module_name)
unquote(Keyword.get(options, :after))
end
end

quote do
unquote(__MODULE__).__with_file__(
unquote(ctx),
{unquote(module_name), unquote(content)},
unquote(fun)
)
end
end

@doc false
def __with_file__(%{tmp_dir: dir}, {module_name, content}, fun) when is_function(fun, 0) do
path = Path.join([dir, Atom.to_string(module_name)])

try do
File.write!(path, content)
compile_file!(path, dir)

fun.()
after
File.rm!(path)
cleanup_modules([module_name], dir)
end
end

@doc """
Defines a temporary module with the given `module_name`,
returns the compiled modules.

You should clean up the modules by calling `cleanup_modules/2`
after you are done.

Note that the `module_name` is expanded to the caller's module
like `with_tmpmodule/3`.
"""
defmacro deftmpmodule(module_name, ctx, do: block) do
module_name =
module_name
|> Macro.expand(__CALLER__)
|> then(&Module.concat(__CALLER__.module, &1))

content =
"""
defmodule #{Atom.to_string(module_name)} do
#{Macro.to_string(block)}
end
"""

quote do
alias unquote(module_name)

unquote(__MODULE__).__compile_tmpmodule__(
unquote(ctx),
{unquote(module_name), unquote(content)}
)
end
end

@doc false
def __compile_tmpmodule__(%{tmp_dir: dir}, {module_name, content}) do
path = Path.join([dir, Atom.to_string(module_name)])

File.write!(path, content)
compile_file!(path, dir)
end

defp compile_file!(path, dir) do
Code.compiler_options(docs: true, debug_info: true)
{:ok, modules, []} = Kernel.ParallelCompiler.compile_to_path(List.wrap(path), dir)

modules
end

@doc """
Cleans up the modules by removing the beam files and purging the code.
"""
@spec cleanup_modules([module()], dir :: Path.t()) :: term()
def cleanup_modules(mods, dir) do
Enum.each(mods, fn mod ->
File.rm(Path.join([dir, "#{mod}.beam"]))
:code.purge(mod)
true = :code.delete(mod)
end)
end

@doc """
Fetches the types for the given module.
"""
@spec fetch_types!(module() | binary) :: [tuple()]
def fetch_types!(module) when is_atom(module) or is_binary(module) do
module
|> Code.Typespec.fetch_types()
|> case do
:error -> refute "Failed to fetch types for module #{module}"
{:ok, types} -> types
end
end

@doc """
Fetches the doc for the given module or its functions and types.
"""
def fetch_doc!(module, :moduledoc) when is_atom(module) do
case Code.fetch_docs(module) do
{:docs_v1, _, :elixir, _, %{"en" => doc}, _, _} -> doc
_ -> refute "Failed to fetch moduledoc for #{module}"
end
end

def fetch_doc!(module, {type, name, arity}) when is_atom(module) do
with(
{:docs_v1, _, :elixir, _, _, _, docs} <- Code.fetch_docs(module),
{_, _, _, %{"en" => doc}, _} <- List.keyfind(docs, {type, name, arity}, 0)
) do
doc
else
_other -> refute "Failed to fetch doc for #{inspect({type, name, arity})} at #{module}"
end
end

@doc """
Asserts that the expected types are equal to the actual types by comparing
their formatted strings.
"""
@spec assert_type(expected :: [tuple()], actual :: [tuple()]) :: term()
def assert_type(expected, actual) do
expected_types = format_types(expected)

if String.length(String.trim(expected_types)) === 0 do
refute "Expected types are empty: #{inspect(expected)}"
end

assert expected_types == format_types(actual)
end

@spec format_types([tuple()]) :: String.t()
def format_types(types) do
types
|> Enum.sort_by(fn {_, {name, _, args}} -> {name, length(args)} end)
|> Enum.map_join(
"\n",
fn {kind, type} ->
ast = Code.Typespec.type_to_quoted(type)
"@#{kind} #{Macro.to_string(ast)}"
end
)
end
end
Loading