Skip to content

Commit

Permalink
Report coverage for rewired modules (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
lexun authored Oct 3, 2022
1 parent 0dbbec2 commit b10a5d2
Show file tree
Hide file tree
Showing 11 changed files with 293 additions and 23 deletions.
4 changes: 2 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
elixir 1.10.4
erlang 22.3
elixir 1.14.0-otp-25
erlang 25.0
18 changes: 10 additions & 8 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,31 @@ jobs:
- elixir: 1.7.4
otp_release: 19.3
script:
- mix compile --warning-as-errors
- mix compile
- mix test
- elixir: 1.8.2
otp_release: 20.3
script:
- mix compile --warning-as-errors
- mix test
- mix test --cover
- elixir: 1.9.4
otp_release: 20.3
script:
- mix format --check-formatted
- mix compile --warning-as-errors
- mix test
- mix test --cover
- elixir: 1.10.4
otp_release: 23.0
script:
- mix format --check-formatted
- mix compile --warning-as-errors
- mix test
- mix test --cover
- elixir: 1.11
otp_release: 23.0
script:
- mix compile --warning-as-errors
- mix test --cover
- elixir: 1.14
otp_release: 25.0
script:
- mix format --check-formatted
- mix compile --warning-as-errors
- mix test

- mix test --cover
3 changes: 3 additions & 0 deletions fixtures/covered.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
defmodule Rewire.Covered do
def hello(), do: Rewire.Hello.hello()
end
50 changes: 50 additions & 0 deletions fixtures/test_cover.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
defmodule Rewire.TestCover do
@moduledoc false

@doc false
def start(compile_path, _opts) do
:cover.stop()
:cover.start()

compile_path
|> String.to_charlist()
|> :cover.compile_beam_directory()

&execute/0
end

defp execute do
{:result, results, _fail} = :cover.analyse(:calls, :function)

results
|> Enum.find(fn
{{Rewire.Covered, :hello, 0}, _} -> true
_ -> false
end)
|> validate
end

defp validate({{Rewire.Covered, :hello, 0}, 8}) do
:ok
end

defp validate({{Rewire.Covered, :hello, 0}, times_called}) when is_integer(times_called) do
IO.puts("""
Cover results are incorrect!
Rewired.Covered.hello/0 was expected to be called 8 times,
but coverage reports #{times_called}"
""")

throw(:test_cover_failed)
end

defp validate(_) do
IO.puts("""
Cover results are incorrect!
Rewired.Covered.hello/0 was expected to be called 8 times,
but no coverage was reported.
""")

throw(:test_cover_failed)
end
end
136 changes: 136 additions & 0 deletions lib/rewire/cover.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
defmodule Rewire.Cover do
@moduledoc """
Abuse cover private functions to move stuff around.
Adapted from mimic's solution:
https://github.com/edgurgel/mimic/blob/5d3e651ce78473195c70ab86c9d2d0609a12dd3b/lib/mimic/cover.ex
Which is based on meck's solution:
https://github.com/eproxus/meck/blob/2c7ba603416e95401500d7e116c5a829cb558665/src/meck_cover.erl#L67-L91
"""

use Agent

@tmp_coverdata_dir Mix.Project.build_path() <> "/rewire_coverdata"

def start_link(_) do
if enabled?() do
export_private_functions()
ExUnit.after_suite(&after_suite/1)
end

Agent.start_link(fn -> [] end, name: __MODULE__)
end

def enable_abstract_code do
if enabled?() && Version.compare(System.version(), "1.14.0") in [:gt, :eq] do
# Code.eval_quoted/3 does not seem to include abstract code in generated
# modules by default without this after Elixir 1.14.
apply(Code, :put_compiler_option, [:debug_info, true])
end
end

def compile(eval_result, old_mod_name) do
if enabled?() do
case eval_result do
# Capture created module and compile for coverage reporting.
{{:module, module, binary, _}, []} ->
apply(:cover, :compile_beams, [[{module, binary}]])

{[_, {:module, module, binary, _}, _], []} ->
apply(:cover, :compile_beams, [[{module, binary}]])

{[_, [{:module, module, binary, _}], _], []} ->
apply(:cover, :compile_beams, [[{module, binary}]])

_ ->
IO.warn("Failed to compile code coverage for: #{old_mod_name}")
end
end
end

def track(new_module, original_module) do
if enabled?(original_module) do
Agent.update(__MODULE__, &[{new_module, original_module} | &1])
end
end

defp enabled?(), do: Version.compare(System.version(), "1.8.0") in [:gt, :eq]
defp enabled?(module), do: enabled?() && :cover.is_compiled(module) != false

defp after_suite(_) do
Agent.get(__MODULE__, & &1)
|> Enum.each(fn {new_module, mod} ->
replace_coverdata!(new_module, mod)
end)

File.rm_rf!(@tmp_coverdata_dir)
end

defp export_private_functions do
{_, binary, _} = :code.get_object_code(:cover)
{:ok, {_, [{_, {_, abstract_code}}]}} = :beam_lib.chunks(binary, [:abstract_code])
{:ok, module, binary} = :compile.forms(abstract_code, [:export_all])
:code.load_binary(module, '', binary)
end

defp replace_coverdata!(rewired, original_module) do
rewired_path = export_coverdata!(rewired)
rewrite_coverdata!(rewired_path, original_module)
:ok = :cover.import(String.to_charlist(rewired_path))
File.rm(rewired_path)
end

defp export_coverdata!(module) do
File.mkdir_p!(@tmp_coverdata_dir)
path = Path.expand("#{module}-#{:os.getpid()}.coverdata", @tmp_coverdata_dir)
:ok = :cover.export(String.to_charlist(path), module)
path
end

defp rewrite_coverdata!(path, module) do
terms = get_terms(path)
terms = replace_module_name(terms, module)
write_coverdata!(path, terms)
end

defp replace_module_name(terms, module) do
Enum.map(terms, fn term -> do_replace_module_name(term, module) end)
end

defp do_replace_module_name({:file, old, file}, module) do
{:file, module, String.replace(file, to_string(old), to_string(module))}
end

defp do_replace_module_name({bump = {:bump, _mod, _, _, _, _}, value}, module) do
{put_elem(bump, 1, module), value}
end

defp do_replace_module_name({_mod, clauses}, module) do
{module, replace_module_name(clauses, module)}
end

defp do_replace_module_name(clause = {_mod, _, _, _, _}, module) do
put_elem(clause, 0, module)
end

defp get_terms(path) do
{:ok, resource} = File.open(path, [:binary, :read, :raw])
terms = get_terms(resource, [])
File.close(resource)
terms
end

defp get_terms(resource, terms) do
case apply(:cover, :get_term, [resource]) do
:eof -> terms
term -> get_terms(resource, [term | terms])
end
end

defp write_coverdata!(path, terms) do
{:ok, resource} = File.open(path, [:write, :binary, :raw])
Enum.each(terms, fn term -> apply(:cover, :write, [term, resource]) end)
File.close(resource)
end
end
22 changes: 17 additions & 5 deletions lib/rewire/module.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ defmodule Rewire.Module do
# Traverse through AST and create new module with rewired dependencies.
old_mod_name = mod |> Atom.to_string() |> String.trim_leading("Elixir.")
new_mod_name = Map.fetch!(opts, :new_module_ast) |> module_ast_to_name()
new_module = "Elixir.#{new_mod_name}" |> String.to_atom()

new_ast =
traverse(
Expand All @@ -42,9 +43,14 @@ defmodule Rewire.Module do
["new code:", Macro.to_string(quote do: unquote(new_ast)) <> "\n"] |> Enum.join("\n\n")
end)

Rewire.Cover.enable_abstract_code()

# Now evaluate the new module's AST so the file location is correct.
Code.eval_quoted(new_ast, [], file: source_path)
"Elixir.#{new_mod_name}" |> String.to_atom()
|> Rewire.Cover.compile(old_mod_name)

Rewire.Cover.track(new_module, mod)
new_module
end

defp traverse(ast, old_module_ast, new_module_ast, opts) do
Expand All @@ -68,7 +74,9 @@ defmodule Rewire.Module do
%{overrides_completed: overrides_completed} = new_acc
report_broken_overrides(old_module_ast, Map.keys(overrides), overrides_completed, opts)

new_ast
# In some cases the generated AST is unnecessarily nested, which prevents
# capturing the created module and compiling for coverage reporting.
flatten(new_ast)
end

# Changes the rewired module's name to prevent a naming collision.
Expand Down Expand Up @@ -255,9 +263,7 @@ defmodule Rewire.Module do
[unused_override] ->
raise CompileError,
description:
"unable to rewire '#{module_ast_to_name(module_ast)}': dependency '#{
module_ast_to_name(unused_override)
}' not found",
"unable to rewire '#{module_ast_to_name(module_ast)}': dependency '#{module_ast_to_name(unused_override)}' not found",
file: file,
line: line

Expand All @@ -275,4 +281,10 @@ defmodule Rewire.Module do
line: line
end
end

defp flatten({:__block__, [], [[], {:defmodule, _, _} = nested, []]}) do
{:__block__, [], [nested]}
end

defp flatten(ast), do: ast
end
10 changes: 10 additions & 0 deletions lib/rewire/setup.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule Rewire.Setup do
@moduledoc false

use Application

def start(_, _) do
Application.ensure_all_started(:ex_unit)
Supervisor.start_link([Rewire.Cover], name: Rewire.Supervisor, strategy: :one_for_one)
end
end
6 changes: 4 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ defmodule Rewire.MixProject do
deps: deps(),
package: package(),
description: description(),
source_url: "https://github.com/stephanos/rewire"
source_url: "https://github.com/stephanos/rewire",
test_coverage: [tool: Rewire.TestCover]
]
end

Expand All @@ -21,7 +22,8 @@ defmodule Rewire.MixProject do

def application do
[
extra_applications: [:logger]
extra_applications: [:logger, :tools],
mod: {Rewire.Setup, []}
]
end

Expand Down
8 changes: 4 additions & 4 deletions test/rewire_block_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ defmodule RewireBlockTest do

import ExUnit.CaptureIO

defmodule StringMock do
def titlecase("hello"), do: "Hello!"
end

describe "rewire a block with" do
test "non-aliased dependency" do
output =
Expand Down Expand Up @@ -87,10 +91,6 @@ defmodule RewireBlockTest do
end

test "an Erlang module" do
defmodule StringMock do
def titlecase("hello"), do: "Hello!"
end

output =
capture_io(:stderr, fn ->
rewire Rewire.HelloErlang, string: StringMock do
Expand Down
26 changes: 26 additions & 0 deletions test/rewire_covered_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
defmodule RewireCoveredTest do
use ExUnit.Case
import Rewire

# Set up for coverage validation in fixtures/test_cover.ex
# This should add up to a total of 8 calls to Rewire.Covered.hello/0

test "the module being rewired reports test coverage" do
rewire Rewire.Covered, Hello: Bonjour

# Two through the original module
assert Rewire.Covered.hello() == "hello"
assert Rewire.Covered.hello() == "hello"
# And two through the rewired copy
assert Covered.hello() == "bonjour"
assert Covered.hello() == "bonjour"
end

test "coverage from second rewire stacks with the first" do
rewire Rewire.Covered, Hello: Bonjour
assert Rewire.Covered.hello() == "hello"
assert Rewire.Covered.hello() == "hello"
assert Covered.hello() == "bonjour"
assert Covered.hello() == "bonjour"
end
end
Loading

0 comments on commit b10a5d2

Please sign in to comment.