diff options
author | ILYA Khlopotov <iilyak@apache.org> | 2019-05-27 19:17:16 +0000 |
---|---|---|
committer | ILYA Khlopotov <iilyak@apache.org> | 2019-07-29 11:24:37 +0000 |
commit | d0ccfa2b463b7087c1c3a766ca3ae963c1d1738d (patch) | |
tree | 9a5f34affa5f647ecf0bdbe968276572387d6552 | |
parent | cf60cffb44bf18889aa8362b616bafb7bed87445 (diff) | |
download | couchdb-d0ccfa2b463b7087c1c3a766ca3ae963c1d1738d.tar.gz |
Add chained setups
-rw-r--r-- | test/elixir/README.md | 143 | ||||
-rw-r--r-- | test/elixir/lib/ex_unit.ex | 44 | ||||
-rw-r--r-- | test/elixir/lib/setup.ex | 97 | ||||
-rw-r--r-- | test/elixir/lib/setup/common.ex | 22 | ||||
-rw-r--r-- | test/elixir/lib/step.ex | 44 | ||||
-rw-r--r-- | test/elixir/lib/step/config.ex | 33 | ||||
-rw-r--r-- | test/elixir/lib/step/create_db.ex | 53 | ||||
-rw-r--r-- | test/elixir/lib/step/start.ex | 85 | ||||
-rw-r--r-- | test/elixir/lib/step/user.ex | 104 | ||||
-rw-r--r-- | test/elixir/lib/utils.ex | 60 |
10 files changed, 685 insertions, 0 deletions
diff --git a/test/elixir/README.md b/test/elixir/README.md index a59b4df90..f7691ad3c 100644 --- a/test/elixir/README.md +++ b/test/elixir/README.md @@ -111,3 +111,146 @@ X means done, - means partially - [ ] Port view_pagination.js - [ ] Port view_sandboxing.js - [ ] Port view_update_seq.js + +# Using ExUnit to write unit tests + +Elixir has a number of benefits which makes writing unit tests easier. +For example it is trivial to do codegeneration of tests. +Bellow we present a few use cases where code-generation is really helpful. + +## How to write ExUnit tests + +1. Create new file in test/exunit/ directory (the file name should match *_test.exs) +2. In case it is a first file in the directory create test_helper.exs (look at src/couch/test/exunit/test_helper.exs to get an idea) +3. define test module which does `use Couch.Test.ExUnit.Case` +4. Define test cases in the module + +You can run tests either: +- using make: `make exunit` +- using mix: BUILDDIR=`pwd` ERL_LIBS=`pwd`/src MIX_ENV=test mix test --trace + +## Generating tests from spec + +Sometimes we have some data in structured format and want +to generate test cases using that data. This is easy in Elixir. +For example suppose we have following spec: +``` +{ + "{db_name}/_view_cleanup": { + "roles": ["_admin"] + } +} +``` +We can use this spec to generate test cases +``` +defmodule GenerateTestsFromSpec do + use ExUnit.Case + require Record + Record.defrecordp :user_ctx, Record.extract(:user_ctx, from_lib: "couch/include/couch_db.hrl") + Record.defrecordp :httpd, Record.extract(:httpd, from_lib: "couch/include/couch_db.hrl") + + {:ok, spec_bin} = File.read("roles.json") + spec = :jiffy.decode(spec_bin, [:return_maps]) + Enum.each spec, fn {path, path_spec} -> + roles = path_spec["roles"] + @roles roles + @path_parts String.split(path, "/") + test "Access with `#{inspect(roles)}` roles" do + req = httpd(path_parts: @path_parts, user_ctx: user_ctx(roles: @roles)) + :chttpd_auth_request.authorize_request(req) + end + end +end +``` +As a result we would get +``` +GenerateTestsFromSpec + * test Access with `["_admin"]` roles (0.00ms) +``` + +## Test all possible combinations + +Sometimes we want to test all possible permutations for parameters. +This can be accomplished using something like the following: + +``` +defmodule Permutations do + use ExUnit.Case + pairs = :couch_tests_combinatorics.product([ + [:remote, :local], [:remote, :local] + ]) + for [source, dest] <- pairs do + @source source + @dest dest + test "Replication #{source} -> #{dest}" do + assert :ok == :ok + end + end +end +``` + +This would produce following tests +``` +Permutations + * test Replication remote -> remote (0.00ms) + * test Replication local -> remote (0.00ms) + * test Replication remote -> local (0.00ms) + * test Replication local -> local (0.00ms) +``` + +## Reuseing of common setups + +The setup functions are quite similar in lots of tests therefore it makes +sense to reuse them. The idea is to add shared setup functions into either +- test/elixir/lib/setup/common.ex +- test/elixir/lib/setup/<something>.ex + +The setup functions looks like the following: +``` +defmodule Foo do + alias Couch.Test.Setup.Step + + def httpd_with_admin(setup) do + setup + |> Step.Start.new(:start, extra_apps: [:chttpd]) + |> Step.User.new(:admin, roles: [:server_admin]) + end +end +``` + +These parts of a setup chain can be invoked as follows: +``` +defmodule Couch.Test.CRUD do + use Couch.Test.ExUnit.Case + alias Couch.Test.Utils + + alias Couch.Test.Setup + + alias Couch.Test.Setup.Step + + def with_db(context, setup) do + setup = + setup + |> Setup.Common.httpd_with_db() + |> Setup.run() + + context = + Map.merge(context, %{ + db_name: setup |> Setup.get(:db) |> Step.Create.DB.name(), + base_url: setup |> Setup.get(:start) |> Step.Start.clustered_url(), + user: setup |> Setup.get(:admin) |> Step.User.name() + }) + + {context, setup} + end + + describe "Database CRUD using Fabric API" do + @describetag setup: &__MODULE__.with_db/2 + test "Create DB", ctx do + IO.puts("base_url: #{ctx.base_url}") + IO.puts("admin: #{ctx.user}") + IO.puts("db_name: #{ctx.db_name}") + end + end +end +```
\ No newline at end of file diff --git a/test/elixir/lib/ex_unit.ex b/test/elixir/lib/ex_unit.ex new file mode 100644 index 000000000..7abba07ef --- /dev/null +++ b/test/elixir/lib/ex_unit.ex @@ -0,0 +1,44 @@ +defmodule Couch.Test.ExUnit.Case do + @moduledoc """ + Template for ExUnit test case. It can be used as follows: + ``` + defmodule Couch.Test.CRUD do + use Couch.Test.ExUnit.Case + ... + def with_db(context, setup) do + setup = setup + |> Step.Start.new(:start, extra_apps: [:chttpd]) + |> Setup.run + context = Map.merge(context, %{ + base_url: setup |> Setup.get(:start) |> Step.Start.clustered_url + }) + {context, setup} + end + describe "Group of tests" do + @describetag setup: &__MODULE__.with_db/2 + test "Single test in a group", ctx do + ctx.base_url + end + ... + end + ``` + """ + + use ExUnit.CaseTemplate + alias Couch.Test.Setup + + using do + quote do + require Logger + use ExUnit.Case + end + end + + setup context do + case context do + %{:setup => setup_fun} -> + {:ok, Setup.setup(context, setup_fun)} + _ -> {:ok, context} + end + end +end
\ No newline at end of file diff --git a/test/elixir/lib/setup.ex b/test/elixir/lib/setup.ex new file mode 100644 index 000000000..037988521 --- /dev/null +++ b/test/elixir/lib/setup.ex @@ -0,0 +1,97 @@ +defmodule Couch.Test.Setup do + @moduledoc """ + Allows to chain setup functions. + Example of using: + + ``` + alias Couch,Test.Utils + def with_db_name(context, setup) do + setup = + setup + |> Step.Start.new(:start, extra_apps: [:chttpd]) + |> Step.User.new(:admin, roles: [:server_admin]) + |> Setup.run() + + context = + Map.merge(context, %{ + db_name: Utils.random_name("db") + base_url: setup |> Setup.get(:start) |> Step.Start.clustered_url(), + user: setup |> Setup.get(:admin) |> Step.User.name() + }) + {context, setup} + end + + @tag setup: &__MODULE__.with_db_name/2 + test "Create", %{db_name: db_name, user: user} do + ... + end + ``` + """ + import ExUnit.Callbacks, only: [on_exit: 1] + import ExUnit.Assertions, only: [assert: 2] + require Logger + + alias Couch.Test.Setup + alias Couch.Test.Setup.Step + defstruct stages: [], by_type: %{}, state: %{} + + def step(%Setup{stages: stages} = setup, id, step) do + %{setup | stages: [{id, step} | stages]} + end + + defp setup_step({id, step}, %Setup{state: state, by_type: by_type} = setup) do + %module{} = step + # credo:disable-for-next-line Credo.Check.Warning.LazyLogging + Logger.debug("Calling 'setup/2' for '#{module}'") + step = module.setup(setup, step) + state = Map.put(state, id, step) + by_type = Map.update(by_type, module, [id], fn ids -> [id | ids] end) + on_exit(fn -> + # credo:disable-for-next-line Credo.Check.Warning.LazyLogging + Logger.debug("Calling 'teardown/3' for '#{module}'") + try do + module.teardown(setup, step) + :ok + catch + _ -> :ok + _, _ -> :ok + end + end) + {{id, step}, %{setup | state: state, by_type: by_type}} + end + + def run(%Setup{stages: stages} = setup) do + {stages, setup} = stages + |> Enum.reverse + |> Enum.map_reduce(setup, &setup_step/2) + %{setup | stages: stages} + end + + def setup(ctx) do + Map.get(ctx, :__setup) + end + + def setup(ctx, setup_fun) do + setup = %Setup{} |> Step.Config.new(:test_config, config_file: nil) + {ctx, setup} = setup_fun.(ctx, setup) + assert not Map.has_key?(ctx, :__setup), "Key `__setup` is reserved for internal purposes" + Map.put(ctx, :__setup, setup) + end + + def completed?(%Setup{by_type: by_type}, step) do + Map.has_key?(by_type, step) + end + + def all_for(%Setup{by_type: by_type, state: state}, step_module) do + Map.take(state, by_type[step_module] || []) + end + + def reduce_for(setup, step_module, acc, fun) do + Enum.reduce(all_for(setup, step_module), acc, fun) + end + + def get(%Setup{state: state}, id) do + state[id] + end + +end
\ No newline at end of file diff --git a/test/elixir/lib/setup/common.ex b/test/elixir/lib/setup/common.ex new file mode 100644 index 000000000..3b59e9476 --- /dev/null +++ b/test/elixir/lib/setup/common.ex @@ -0,0 +1,22 @@ +defmodule Couch.Test.Setup.Common do + @moduledoc """ + A set of common setup pipelines for reuse + + - httpd_with_admin - chttpd is started and new admin is created + - httpd_with_db - httpd_with_admin and new database is created + """ + alias Couch.Test.Setup.Step + + def httpd_with_admin(setup) do + setup + |> Step.Start.new(:start, extra_apps: [:chttpd]) + |> Step.User.new(:admin, roles: [:server_admin]) + end + + def httpd_with_db(setup) do + setup + |> httpd_with_admin() + |> Step.Create.DB.new(:db) + end + +end
\ No newline at end of file diff --git a/test/elixir/lib/step.ex b/test/elixir/lib/step.ex new file mode 100644 index 000000000..316d765aa --- /dev/null +++ b/test/elixir/lib/step.ex @@ -0,0 +1,44 @@ +defmodule Couch.Test.Setup.Step do + @moduledoc """ + A behaviour module for implementing custom setup steps for future reuse. + + Every module implementing this behaviour must implement following three functions: + - new + - setup + - teardown + + Here is an example of a custom step + ``` + defmodule Couch.Test.Setup.Step.Foo do + + alias Couch.Test.Setup + + defstruct [:foo_data, :foo_arg] + + def new(setup, id, arg: arg) do + setup |> Setup.step(id, %__MODULE__{foo_arg: arg}) + end + + def setup(_setup, %__MODULE__{foo_arg: arg} = step) do + ... + foo_data = ... + %{step | foo_data: foo_data} + end + + def teardown(_setup, _step) do + end + + def get_data(%__MODULE__{foo_data: data}) do + data + end + end + ``` + """ + @type t :: struct() + @callback new(setup :: %Couch.Test.Setup{}, id :: atom(), args: Keyword.t()) :: + %Couch.Test.Setup{} + @callback setup(setup :: %Couch.Test.Setup{}, step :: t()) :: + t() + @callback teardown(setup :: %Couch.Test.Setup{}, step :: t()) :: + any() +end
\ No newline at end of file diff --git a/test/elixir/lib/step/config.ex b/test/elixir/lib/step/config.ex new file mode 100644 index 000000000..9d9ac8eab --- /dev/null +++ b/test/elixir/lib/step/config.ex @@ -0,0 +1,33 @@ +defmodule Couch.Test.Setup.Step.Config do + @moduledoc """ + This setup reads configuration for a test run. + It is not supposed to be called manually. + """ + + alias Couch.Test.Setup + + defstruct [:config, :config_file] + + def new(setup, id, config_file: config_file) do + setup |> Setup.step(id, %__MODULE__{config_file: config_file}) + end + + def setup(_setup, %__MODULE__{config_file: config_file} = step) do + # TODO we would need to access config file here + %{step | config: %{ + backdoor: %{ + protocol: "http" + }, + clustered: %{ + protocol: "http" + } + }} + end + + def teardown(_setup, _step) do + end + + def get(%__MODULE__{config: config}) do + config + end +end
\ No newline at end of file diff --git a/test/elixir/lib/step/create_db.ex b/test/elixir/lib/step/create_db.ex new file mode 100644 index 000000000..3cca3c55a --- /dev/null +++ b/test/elixir/lib/step/create_db.ex @@ -0,0 +1,53 @@ +defmodule Couch.Test.Setup.Step.Create.DB do + @moduledoc """ + This setup step creates a database with given name. + If name is not provided random name would be used. + + Example + setup + ... + |> Setup.Step.Create.DB.new(:db) + ... + |> Setup.run + ... + + db_name = setup |> Setup.get(:db) |> Setup.Step.Create.DB.name + """ + alias Couch.Test.Setup + alias Couch.Test.Setup.Step + alias Couch.Test.Utils + + defstruct [:name] + + import ExUnit.Assertions, only: [assert: 1, assert: 2] + + import Utils + + @admin {:user_ctx, user_ctx(roles: ["_admin"])} + + def new(setup, id) do + new(setup, id, name: Utils.random_name("db")) + end + + def new(setup, id, name: name) do + setup |> Setup.step(id, %__MODULE__{name: name}) + end + + def setup(setup, %__MODULE__{name: name} = step) do + assert Setup.completed?(setup, Step.Start), "Require `Start` step" + assert :fabric in Step.Start.apps(), "Fabric is not started" + res = :fabric.create_db(name, [@admin]) + assert res in [:ok, :accepted], "Cannot create `#{name}` database" + step + end + + def teardown(setup, %__MODULE__{name: name} = step) do + :fabric.delete_db(name, [@admin]) + :ok + end + + def name(%__MODULE__{name: name}) do + name + end + +end
\ No newline at end of file diff --git a/test/elixir/lib/step/start.ex b/test/elixir/lib/step/start.ex new file mode 100644 index 000000000..ea7c70f5a --- /dev/null +++ b/test/elixir/lib/step/start.ex @@ -0,0 +1,85 @@ +defmodule Couch.Test.Setup.Step.Start do + @moduledoc """ + Step to start a set of couchdb applications. By default it starts + list of applications from DEFAULT_APPS macro defined in `test_util.erl`. + At the time of writing this list included: + - inets + - ibrowse + - ssl + - config + - couch_epi + - couch_event + - couch + + It is possible to specify additional list of applications to start. + + This setup is also maintains `clustered_url` and `backdoor_url` for future use. + The value for `clustered_url` could be nil if :chttpd app is not included in extra_apps. + + Example + setup + |> Setup.Step.Start.new(:start, extra_apps: [:fabric, :chttpd]) + ... + |> Setup.run + ... + + started_apps = Setup.Step.Start.apps + clustered_url = setup |> Setup.get(:start) |> Setup.Step.Start.clustered_url + backdoor_url = setup |> Setup.get(:start) |> Setup.Step.Start.backdoor_url + """ + alias Couch.Test.Setup + alias Couch.Test.Setup.Step + + defstruct [:test_ctx, :extra_apps, :clustered_url, :backdoor_url] + + def new(setup, id, extra_apps: extra_apps) do + setup |> Setup.step(id, %__MODULE__{extra_apps: extra_apps || []}) + end + + def setup(setup, %__MODULE__{extra_apps: extra_apps} = step) do + test_config = setup |> Setup.get(:test_config) |> Step.Config.get() + protocol = test_config[:backdoor][:protocol] || "http" + test_ctx = :test_util.start_couch(extra_apps) + addr = :config.get('couch_httpd', 'bind_address', '127.0.0.1') + port = :mochiweb_socket_server.get(:couch_httpd, :port) + backdoor_url = "#{protocol}://#{addr}:#{port}" + clustered_url = + if :chttpd in extra_apps do + protocol = test_config[:clustered][:protocol] || "http" + addr = :config.get('chttpd', 'bind_address', '127.0.0.1') + port = :mochiweb_socket_server.get(:chttpd, :port) + "#{protocol}://#{addr}:#{port}" + else + nil + end + %{step | + test_ctx: test_ctx, + clustered_url: clustered_url, + backdoor_url: backdoor_url + } + end + + def teardown(_setup, %___MODULE__{test_ctx: test_ctx}) do + :test_util.stop_couch(test_ctx) + end + + def backdoor_url(%__MODULE__{backdoor_url: url}) do + url + end + + def clustered_url(%__MODULE__{clustered_url: url}) do + url + end + + def extra_apps(%__MODULE__{extra_apps: apps}) do + apps + end + + @doc """ + Returns list of currently running applications + """ + def apps() do + for {x, _, _} <- Application.started_applications, do: x + end + +end
\ No newline at end of file diff --git a/test/elixir/lib/step/user.ex b/test/elixir/lib/step/user.ex new file mode 100644 index 000000000..5a1cab33c --- /dev/null +++ b/test/elixir/lib/step/user.ex @@ -0,0 +1,104 @@ +defmodule Couch.Test.Setup.Step.User do + @moduledoc """ + Step to create user with given list of roles. + The :server_admin is a special role which is used to put user + into `admins` section of a config instead of a database. + + Example + setup + |> Setup.Step.User.new(:admin, roles: [:server_admin]) + ... + |> Setup.run + ... + + user = setup |> Setup.get(:admin) |> Step.User.name() + """ + + alias Couch.Test.Setup + alias Couch.Test.Setup.Step + alias Couch.Test.Utils + + import ExUnit.Callbacks, only: [on_exit: 1] + + defstruct [:roles, :name, :password, :users_db] + + import ExUnit.Assertions, only: [assert: 1, assert: 2] + + import Utils + + @admin {:user_ctx, user_ctx(roles: ["_admin"])} + + def new(setup, id, roles: roles) do + setup |> Setup.step(id, %__MODULE__{roles: roles || []}) + end + + def setup(setup, %__MODULE__{roles: roles} = step) do + users_db = IO.chardata_to_string( + :config.get('chttpd_auth', 'authentication_db', '_users')) + if not Utils.db_exists?(users_db) do + on_exit fn -> + :fabric.delete_db(users_db, [@admin]) + end + res = :fabric.create_db(users_db, [@admin]) + assert res in [:ok, :accepted], "Cannot create `users` database #{users_db}" + end + + if :server_admin in roles do + name = Utils.random_name("admin") + pass = Utils.random_password() + :config.set( + 'admins', String.to_charlist(name), String.to_charlist(pass), false) + %{step | + name: name, + password: pass, + users_db: users_db + } + else + name = Utils.random_name("admin") + pass = Utils.random_password() + doc_id = "org.couchdb.user:#{name}" + user_doc = :couch_doc.from_json_obj(%{ + _id: doc_id, + name: name, + type: "user", + roles: roles, + password: pass + }) + res = :fabric.update_doc(users_db, user_doc, [@admin]) + assert res in [:ok, :accepted], "Cannot create user document" + %{step | + name: name, + password: pass, + users_db: users_db, + roles: roles + } + end + end + + def teardown(setup, %__MODULE__{name: name, users_db: users_db, roles: roles} = step) do + if :server_admin in roles do + :config.delete("admins", String.to_charlist(name), false) + else + doc_id = "org.couchdb.user:#{name}" + assert {:ok, doc_info(revs: [rev | _])} = :fabric.get_doc_info(users_db) + doc = :couch_doc.from_json_obj(%{ + _id: doc_id, + _rev: rev, + _deleted: true + }) + assert {:ok, _resp} = :fabric.update_doc(users_db, doc, [@admin]) + end + :ok + end + + def name(%__MODULE__{name: name}) do + name + end + def password(%__MODULE__{password: pass}) do + pass + end + def credentials(%__MODULE__{name: name, password: pass}) do + {name, pass} + end + +end
\ No newline at end of file diff --git a/test/elixir/lib/utils.ex b/test/elixir/lib/utils.ex new file mode 100644 index 000000000..4d3f33f69 --- /dev/null +++ b/test/elixir/lib/utils.ex @@ -0,0 +1,60 @@ +defmodule Couch.Test.Utils do + require Record + @moduledoc "Helper functions for testing" + Record.defrecord :user_ctx, Record.extract( + :user_ctx, from_lib: "couch/include/couch_db.hrl") + + Record.defrecord :doc_info, Record.extract( + :doc_info, from_lib: "couch/include/couch_db.hrl") + + def random_name(prefix) do + time = :erlang.monotonic_time() + umi = :erlang.unique_integer([:monotonic]) + "#{prefix}-#{time}-#{umi}" + end + + def random_password() do + rand_bytes = :crypto.strong_rand_bytes(16) + rand_bytes + |> :base64.encode() + |> String.slice(0..16) + end + + def db_exists?(db_name) do + try do + :fabric.get_db_info(db_name) + catch + :error, :database_does_not_exist -> false + end + end + + @doc """ + In some cases we need to access record definition at compile time. + We cannot use Record.defrecord in such cases. This helper function + can be used instead. Use it as follows: + ``` + defmodule Foo do + admin_ctx = {:user_ctx, Utils.erlang_record( + :user_ctx, "couch/include/couch_db.hrl", roles: ["_admin"])} + end + ``` + + Longer term we should wrap erlang records as it is done for user_ctx + see beginning of the Utils.ex. In this case we would be able to use + them at compile time in other modules. + ``` + Record.defrecord :user_ctx, Record.extract( + :user_ctx, from_lib: "couch/include/couch_db.hrl") + ``` + """ + def erlang_record(name, from_lib, opts \\ []) do + record_info = Record.extract(name, from_lib: from_lib) + index = [name | Keyword.keys(record_info)] |> Enum.with_index + draft = [name | Keyword.values(record_info)] |> List.to_tuple + opts + |> Enum.reduce(draft, fn + {k, v}, acc -> put_elem(acc, index[k], v) + end) + end + +end
\ No newline at end of file |