Skip to content

Commit

Permalink
Add support for bundles in ExUnit
Browse files Browse the repository at this point in the history
  • Loading branch information
José Valim committed May 28, 2016
1 parent 58377cd commit 056536a
Show file tree
Hide file tree
Showing 5 changed files with 284 additions and 42 deletions.
50 changes: 35 additions & 15 deletions lib/ex_unit/lib/ex_unit/callbacks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ defmodule ExUnit.Callbacks do
@doc false
defmacro __using__(_) do
quote do
@ex_unit_bundle nil
@ex_unit_setup []
@ex_unit_setup_all []

Expand Down Expand Up @@ -122,7 +123,8 @@ defmodule ExUnit.Callbacks do
do_setup(quote(do: _), block)
else
quote do
@ex_unit_setup ExUnit.Callbacks.__callback__(unquote(block)) ++ @ex_unit_setup
@ex_unit_setup ExUnit.Callbacks.__callback__(unquote(block), @ex_unit_bundle) ++
@ex_unit_setup
end
end
end
Expand All @@ -145,7 +147,7 @@ defmodule ExUnit.Callbacks do
quote bind_quoted: [var: escape(var), block: escape(block)] do
name = :"__ex_unit_setup_#{length(@ex_unit_setup)}"
defp unquote(name)(unquote(var)), unquote(block)
@ex_unit_setup [name | @ex_unit_setup]
@ex_unit_setup [{name, @ex_unit_bundle} | @ex_unit_setup]
end
end

Expand All @@ -162,7 +164,9 @@ defmodule ExUnit.Callbacks do
do_setup_all(quote(do: _), block)
else
quote do
@ex_unit_setup_all ExUnit.Callbacks.__callback__(unquote(block)) ++ @ex_unit_setup_all
@ex_unit_bundle && raise "cannot invoke setup_all/1 inside bundle"
@ex_unit_setup_all ExUnit.Callbacks.__callback__(unquote(block), nil) ++
@ex_unit_setup_all
end
end
end
Expand All @@ -183,9 +187,10 @@ defmodule ExUnit.Callbacks do

defp do_setup_all(var, block) do
quote bind_quoted: [var: escape(var), block: escape(block)] do
@ex_unit_bundle && raise "cannot invoke setup_all/2 inside bundle"
name = :"__ex_unit_setup_all_#{length(@ex_unit_setup_all)}"
defp unquote(name)(unquote(var)), unquote(block)
@ex_unit_setup_all [name | @ex_unit_setup_all]
@ex_unit_setup_all [{name, nil} | @ex_unit_setup_all]
end
end

Expand All @@ -211,16 +216,18 @@ defmodule ExUnit.Callbacks do

## Helpers

@reserved [:case, :file, :line, :test, :async, :registered]
@reserved [:case, :file, :line, :test, :async, :registered, :bundle]

@doc false
def __callback__(callback) do
for k <- List.wrap(callback), not is_atom(k) do
raise ArgumentError, "setup/setup_all expect a callback name as an atom or " <>
"a list of callback names, got: #{inspect k}"
end
def __callback__(callback, bundle) do
for k <- List.wrap(callback) do
if not is_atom(k) do
raise ArgumentError, "setup/setup_all expect a callback name as an atom or " <>
"a list of callback names, got: #{inspect k}"
end

callback |> List.wrap() |> Enum.reverse()
{k, bundle}
end |> Enum.reverse()
end

@doc false
Expand Down Expand Up @@ -279,22 +286,35 @@ defmodule ExUnit.Callbacks do
[] ->
quote do: context
[h | t] ->
Enum.reduce t, compile_merge(h), fn(callback, acc) ->
Enum.reduce t, compile_merge(h), fn callback_bundle, acc ->
quote do
context = unquote(acc)
unquote(compile_merge(callback))
unquote(compile_merge(callback_bundle))
end
end
end

quote do
def __ex_unit__(unquote(kind), context), do: unquote(acc)
def __ex_unit__(unquote(kind), context) do
bundle = Map.get(context, :bundle, nil)
unquote(acc)
end
end
end

defp compile_merge(callback) do
defp compile_merge({callback, nil}) do
quote do
unquote(__MODULE__).__merge__(__MODULE__, context, unquote(callback)(context))
end
end

defp compile_merge({callback, bundle}) do
quote do
if unquote(bundle) == bundle do
unquote(compile_merge({callback, nil}))
else
context
end
end
end
end
104 changes: 83 additions & 21 deletions lib/ex_unit/lib/ex_unit/case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ defmodule ExUnit.Case do
* `:line` - the line on which the test was defined
* `:test` - the test name
* `:async` - if the test case is in async mode
* `:type` - the type of the test (`:test`, `:property`, etc)
* `:registered` - used for `ExUnit.Case.register_attribute/3` values
* `:bundle` - the bundle the test belongs to
The following tags customize how tests behaves:
Expand All @@ -132,9 +134,6 @@ defmodule ExUnit.Case do
* `:timeout` - customizes the test timeout in milliseconds (defaults to 60000)
* `:report` - include the given tags and context keys on error reports,
see the "Reporting tags" section
* `:type` - customizes the test's type in reports (defaults to `:test`). The
test type will be converted to a string and pluralized for display. You
can use `ExUnit.plural_rule/2` to set a custom pluralization.
### Reporting tags
Expand Down Expand Up @@ -191,7 +190,7 @@ defmodule ExUnit.Case do
config :logger, backends: []
"""

@reserved [:case, :file, :line, :test, :async, :registered]
@reserved [:case, :file, :line, :test, :async, :registered, :bundle, :type]

@doc false
defmacro __using__(opts) do
Expand All @@ -204,18 +203,19 @@ defmodule ExUnit.Case do
async = !!unquote(opts)[:async]

unless Module.get_attribute(__MODULE__, :ex_unit_tests) do
Enum.each [:ex_unit_tests, :tag, :moduletag, :ex_unit_registered],
Enum.each [:ex_unit_tests, :tag, :bundletag, :moduletag, :ex_unit_registered],
&Module.register_attribute(__MODULE__, &1, accumulate: true)

@before_compile ExUnit.Case
@after_compile ExUnit.Case
@ex_unit_async async
@ex_unit_bundle nil
use ExUnit.Callbacks
end

import ExUnit.Callbacks
import ExUnit.Assertions
import ExUnit.Case, only: [test: 1, test: 2, test: 3]
import ExUnit.Case, only: [bundle: 2, test: 1, test: 2, test: 3]
import ExUnit.DocTest
end
end
Expand Down Expand Up @@ -256,9 +256,8 @@ defmodule ExUnit.Case do
contents = Macro.escape(contents, unquote: true)

quote bind_quoted: binding do
test = :"test #{message}"
ExUnit.Case.register_test(__ENV__, test, [])
def unquote(test)(unquote(var)), do: unquote(contents)
name = ExUnit.Case.register_test(__ENV__, :test, message, [])
def unquote(name)(unquote(var)), do: unquote(contents)
end
end

Expand All @@ -278,9 +277,59 @@ defmodule ExUnit.Case do
"""
defmacro test(message) do
quote bind_quoted: binding do
test = :"test #{message}"
ExUnit.Case.register_test(__ENV__, test, [:not_implemented])
def unquote(test)(_), do: flunk("Not yet implemented")
name = ExUnit.Case.register_test(__ENV__, :test, message, [:not_implemented])

This comment has been minimized.

Copy link
@josevalim

josevalim May 28, 2016

Member

@ThomasArts please note that I have changed the API for register_test. Now you pass the type explicitly in and get the name back. One of the benefits is that properties now can also be bundled together. Since the previous API was never released, it should not be a breaking change from the QuickCheck side as well.

This comment has been minimized.

Copy link
@ThomasArts

ThomasArts via email May 31, 2016

def unquote(name)(_), do: flunk("Not yet implemented")
end
end

@doc """
Bundles tests together.
Every bundle receives a name which is used as prefix for upcoming tests.
Inside a bundle, `ExUnit.Callbacks.setup/1` may be invoked and it will
define a setup callback to run only for the current bundle. The bundle
name is also added as a tag, allowing developers to run tests for
specific bundles.
## Examples
defmodule StringTest do
use ExUnit.Case, async: true
bundle "String.capitalize/1" do
test "first grapheme is in uppercase" do
assert String.capitalize("hello") == "Hello"
end
test "converts remaining graphemes to lowercase" do
assert String.capitalize("HELLO") == "Hello"
end
end
end
When using Mix, you can run all tests in a bundle as:
mix test --only bundle:"String.capitalize/1"
"""
defmacro bundle(message, do: block) do
quote do
if @ex_unit_bundle do
raise "cannot call bundle/2 inside another bundle"
end

@ex_unit_bundle (case unquote(message) do
msg when is_binary(msg) -> msg
msg -> raise ArgumentError, "bundle name must be a string, got: #{inspect msg}"
end)
Module.delete_attribute(__ENV__.module, :bundletag)

This comment has been minimized.

Copy link
@drewolson

drewolson May 28, 2016

Should this be adding the attribute rather than deleting it?

This comment has been minimized.

Copy link
@drewolson

drewolson May 28, 2016

Perhaps I'm misunderstanding, but does bundle modify (prepend to) the name of tests defined within it?

This comment has been minimized.

Copy link
@josevalim

josevalim May 28, 2016

Member

It is deleting any previous bundletag the user may have set at the module level. And yes, it prepends. You can see the tests below for more info. :)


try do
unquote(block)
after
@ex_unit_bundle nil
Module.delete_attribute(__ENV__.module, :bundletag)
end
end
end

Expand Down Expand Up @@ -309,38 +358,51 @@ defmodule ExUnit.Case do
implement macros like `property/3` that works like `test`
but instead defines a property. See `test/3` implementation
for an example of invoking this function.
The test type will be converted to a string and pluralized for
display. You can use `ExUnit.plural_rule/2` to set a custom
pluralization.
"""
def register_test(%{module: mod, file: file, line: line}, name, tags) do
def register_test(%{module: mod, file: file, line: line}, type, name, tags) do
moduletag = Module.get_attribute(mod, :moduletag)

unless moduletag do
raise "cannot define test. Please make sure you have invoked " <>
raise "cannot define #{type}. Please make sure you have invoked " <>
"\"use ExUnit.Case\" in the current module"
end

if Module.defines?(mod, {name, 1}) do
raise ExUnit.DuplicateTestError, ~s("#{name}" is already defined in #{inspect mod})
end

registered_attributes = Module.get_attribute(mod, :ex_unit_registered)
registered = Map.new(registered_attributes, &{&1, Module.get_attribute(mod, &1)})

tag = Module.get_attribute(mod, :tag)
async = Module.get_attribute(mod, :ex_unit_async)

{name, bundle, bundletag} =
if bundle = Module.get_attribute(mod, :ex_unit_bundle) do
{:"#{type} #{bundle} #{name}", bundle, Module.get_attribute(mod, :bundletag)}
else
{:"#{type} #{name}", nil, []}
end

if Module.defines?(mod, {name, 1}) do
raise ExUnit.DuplicateTestError, ~s("#{name}" is already defined in #{inspect mod})
end

tags =
(tags ++ tag ++ moduletag)
(tags ++ tag ++ bundletag ++ moduletag)
|> normalize_tags
|> validate_tags
|> Map.put_new(:type, :test)
|> Map.merge(%{line: line, file: file, registered: registered, async: async})
|> Map.merge(%{line: line, file: file, registered: registered,
async: async, bundle: bundle, type: type})

test = %ExUnit.Test{name: name, case: mod, tags: tags}
Module.put_attribute(mod, :ex_unit_tests, test)

Enum.each [:tag | registered_attributes], fn(attribute) ->
Module.delete_attribute(mod, attribute)
end

name
end

@doc """
Expand Down
86 changes: 86 additions & 0 deletions lib/ex_unit/test/ex_unit/bundle_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
Code.require_file "../test_helper.exs", __DIR__

defmodule ExUnit.BundleTest do
use ExUnit.Case, async: true

@moduletag [attribute_tag: :from_module]

setup _ do
[setup_tag: :from_module]
end

bundle "tags" do
@bundletag attribute_tag: :from_bundle

test "from bundle have higher precedence", context do
assert context.attribute_tag == :from_bundle
end

@tag attribute_tag: :from_test
test "from test have higher precedence", context do
assert context.attribute_tag == :from_test
end
end

bundle "setup" do
setup _ do
[setup_tag: :from_bundle]
end

test "from bundle has higher precedence", context do
assert context.setup_tag == :from_bundle
end
end

bundle "failures" do
test "when using setup_all inside bundle" do
assert_raise RuntimeError, "cannot invoke setup_all/2 inside bundle", fn ->
defmodule Sample do
use ExUnit.Case

bundle "hello" do
setup_all do
[hello: "world"]
end
end
end
end
end

test "when using bundle inside bundle" do
assert_raise RuntimeError, "cannot call bundle/2 inside another bundle", fn ->
defmodule Sample do
use ExUnit.Case

bundle "hello" do
bundle "another" do
end
end
end
end
end

test "when using non-string bundle name" do
assert_raise ArgumentError, "bundle name must be a string, got: :not_allowed", fn ->
defmodule Sample do
use ExUnit.Case

bundle :not_allowed do
end
end
end
end
end

bundle "test names" do
test "come from bundle", context do
assert context.test == :"test test names come from bundle"
end
end

test "from outside bundle", context do
assert context.attribute_tag == :from_module
assert context.setup_tag == :from_module
assert context.test == :"test from outside bundle"
end
end

0 comments on commit 056536a

Please sign in to comment.