Skip to content

Commit

Permalink
test: refactor test_case (#9)
Browse files Browse the repository at this point in the history
Fetching types and docs from modules by compiling them into a temporary
file.
  • Loading branch information
fahchen authored Jul 6, 2024
1 parent c2b6c88 commit d8f9d96
Show file tree
Hide file tree
Showing 11 changed files with 577 additions and 388 deletions.
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

0 comments on commit d8f9d96

Please sign in to comment.