diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cca3681..9319ea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,20 +1,54 @@ -on: push +name: CI + +on: [push, pull_request] jobs: + format: + name: Format & credo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install OTP and Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: 26.x + elixir-version: 1.16.x + + - name: Install dependencies + run: mix deps.get + + - name: Compile with --warnings-as-errors + run: mix compile --warnings-as-errors + + - name: Run "mix format" + run: mix format --check-formatted + test: + name: Test (Elixir ${{matrix.elixir}} | Erlang/OTP ${{matrix.otp}}) runs-on: ubuntu-latest - name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} strategy: + fail-fast: false matrix: - otp: ['25.0'] - elixir: ['1.13.4'] + include: + - otp: 26.x + elixir: 1.16.x + coverage: true + - otp: 27.x + elixir: 1.17.x + env: + MIX_ENV: test steps: - - uses: actions/checkout@v2 - - uses: erlef/setup-beam@v1 + - uses: actions/checkout@v4 + + - name: Install OTP and Elixir + uses: erlef/setup-beam@v1 with: otp-version: ${{matrix.otp}} elixir-version: ${{matrix.elixir}} - - run: mix deps.get - - run: mix format --check-formatted - - run: mix credo --strict - - run: mix test + + - name: Install dependencies + run: mix deps.get --only test + + - name: Run tests + run: mix test --trace diff --git a/.gitignore b/.gitignore index 6dd5b13..6c7e8e1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ erl_crash.dump *.ez # Ignore package tarball (built via "mix hex.build"). -hammox-*.tar +ham-*.tar # Ignore ElixirLS cache .elixir_ls diff --git a/LICENSE b/LICENSE index ad13cde..1e61891 100644 --- a/LICENSE +++ b/LICENSE @@ -187,6 +187,7 @@ identification within third-party archives. Copyright 2019 Michał Szewczak + Copyright 2024 Eduardo Gurgel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index aa2cd07..0c8aaee 100644 --- a/README.md +++ b/README.md @@ -1,246 +1,115 @@ -# Hammox +# Ham -[![CI](https://github.com/msz/hammox/actions/workflows/ci.yml/badge.svg)](https://github.com/msz/hammox/actions/workflows/ci.yml) -[![Module Version](https://img.shields.io/hexpm/v/hammox.svg)](https://hex.pm/packages/hammox) -[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/hammox/) -[![Total Download](https://img.shields.io/hexpm/dt/hammox.svg)](https://hex.pm/packages/hammox) -[![License](https://img.shields.io/hexpm/l/hammox.svg)](https://github.com/msz/hammox/blob/master/LICENSE) -[![Last Updated](https://img.shields.io/github/last-commit/msz/hammox.svg)](https://github.com/msz/hammox/commits/master) +[![CI](https://github.com/edgurgel/ham/actions/workflows/ci.yml/badge.svg)](https://github.com/edgurgel/ham/actions/workflows/ci.yml) +[![Module Version](https://img.shields.io/hexpm/v/edgurgel.svg)](https://hex.pm/packages/ham) +[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/ham) +[![Total Download](https://img.shields.io/hexpm/dt/ham.svg)](https://hex.pm/packages/ham) +[![License](https://img.shields.io/hexpm/l/ham.svg)](https://github.com/edgurgel/ham/blob/main/LICENSE) +[![Last Updated](https://img.shields.io/github/last-commit/edgurgel/ham.svg)](https://github.com/edgurgel/ham/commits/main) -Hammox is a library for rigorous unit testing using mocks, explicit -behaviours and contract tests. You can use it to ensure both your mocks and -implementations fulfill the same contract. +Ham is a library to validate function arguments and return values against their typespecs. +It was originally extracted out from [Ham](https://github.com/msz/hammox/) but +without the Mox integration. Hammox - Mox = Ham! +Thanks [@msz](https://github.com/msz) for creating Hammox! -It takes the excellent [Mox](https://github.com/plataformatec/mox) library -and pushes its philosophy to its limits, providing automatic contract tests -based on behaviour typespecs while maintaining full compatibility with code -already using Mox. - -Hammox aims to catch as many contract bugs as possible while providing useful -deep stacktraces so they can be easily tracked down and fixed. +The main reason why I wanted to extract it out is so that it can be used as part of Mimic or other testing libraries. ## Installation -If you are currently using [Mox](https://github.com/plataformatec/mox), -delete it from your list of dependencies in `mix.exs`. Then add `:hammox`: +Add `:ham`: ```elixir def deps do [ - {:hammox, "~> 0.7", only: :test} + {:ham, "~> 0.1", only: :test} ] end ``` -## Starting from scratch - -Read ["Mocks and explicit contracts"](http://blog.plataformatec.com.br/2015/10/mocks-and-explicit-contracts/) -by José Valim. Then proceed to the [Mox documentation](https://hexdocs.pm/mox/Mox.html). -Once you are comfortable with Mox, switch to using Hammox. - -## Migrating from Mox - -Replace all occurrences of `Mox` with `Hammox`. Nothing more is required; all -your mock calls in test are now ensured to conform to the behaviour typespec. - -## Example - -### Typical mock setup - -Let's say we have a database which can get us user data. We have a module, -`RealDatabase` (not shown), which implements the following behaviour: -```elixir -defmodule Database do - @callback get_users() :: [binary()] -end -``` -We use this client in a `Stats` module which can aggregate data about users: -```elixir -defmodule Stats do - def count_users(database \\ RealDatabase) do - length(database.get_users()) - end -end -``` -And we create a unit test for it: -```elixir -defmodule StatsTest do - use ExUnit.Case, async: true - - test "count_users/0 returns correct user count" do - assert 2 == Stats.count_users() - end -end -``` +## Using Ham -For this test to work, we would have to start a real instance of the database -and provision it with two users. This is of course unnecessary brittleness — -in a unit test, we only want to test that our Stats code provides correct -results given specific inputs. To simplify, we will create a mocked Database -using Mox and use it in the test: +One can simply let Ham apply a module function using `Ham.apply/2` (function or macro) and it will validate argument and return value: ```elixir -defmodule StatsTest do - use ExUnit.Case, async: true - import Mox - - test "count_users/0 returns correct user count" do - defmock(DatabaseMock, for: Database) - expect(DatabaseMock, :get_users, fn -> - ["joe", "jim"] - end) - - assert 2 == Stats.count_users(DatabaseMock) - end -end -``` -The test now passes as expected. - -### The contract breaks - -Imagine that some time later we want to add error flagging for our database -client. We change `RealDatabase` and the corresponding behaviour, `Database`, -to return an ok/error tuple instead of a raw value: -```elixir -defmodule Database do - @callback get_users() :: {:ok, [binary()]} | {:error, term()} -end -``` - -However, The `Stats.count_users/0` test *will still pass*, even though the -function will break when the real database client is used! This is because -the mock is now invalid — it no longer implements the given behaviour, and -therefore breaks the contract. Even though Mox is supposed to create mocks -following explicit contracts, it does not take typespecs into account. - -This is where Hammox comes in. Simply replace all occurrences of Mox with -Hammox (for example, `import Mox` becomes `import Hammox`, etc) and you -will now get this when trying to run the test: - -```none -** (Hammox.TypeMatchError) -Returned value ["joe", "jim"] does not match type {:ok, [binary()]} | {:error, term()}. +iex> Ham.apply(URI, :decode, ["https%3A%2F%2Felixir-lang.org"]) +"https://elixir-lang.org" +iex> Ham.apply(URI, :char_reserved?, ["a"]) +** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte(). + Value "a" does not match type 0..255. + +iex> Ham.apply(URI.decode("https%3A%2F%2Felixir-lang.org")) +"https://elixir-lang.org" +iex> Ham.apply(URI.char_reserved?("a")) +** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte(). + Value "a" does not match type 0..255. ``` -Now the consistency between the mock and its behaviour is enforced. - -### Completing the triangle - -Hammox automatically checks mocks with behaviours, but what about the real -implementations? The real goal is to keep all units implementing a given -behaviour in sync. - -You can decorate any function with Hammox checks by using `Hammox.protect/2`. -It will return an anonymous function which you can use in place of the -original module function. An example test: +Another way is to pass the args and return value without Ham executing anything: ```elixir -defmodule RealDatabaseTest do - use ExUnit.Case, async: true - - test "get_users/0 returns list of users" do - get_users_0 = Hammox.protect({RealDatabase, :get_users, 0}, Database) - assert {:ok, ["real-jim", "real-joe"]} == get_users_0.() - end -end +iex> Ham.validate(URI, :char_reserved?, ["a"], true) +{:error, + %Ham.TypeMatchError{ + reasons: [ + {:arg_type_mismatch, 0, "a", {:type, {324, 24}, :byte, []}}, + {:type_mismatch, "a", {:type, 0, :range, [{:integer, 0, 0}, {:integer, 0, 255}]}} + ] + }} + +iex> Ham.validate!(URI, :char_reserved?, ["a"], true) +** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte(). + Value "a" does not match type 0..255. ``` -It's a good idea to put setup logic like this in a `setup_all` hook and then -access the protected functions using the test context: - -```elixir -defmodule RealDatabaseTest do - use ExUnit.Case, async: true - - setup_all do - %{get_users_0: Hammox.protect({RealDatabase, :get_users, 0}, Database)} - end - - test "get_users/0 returns list of users", %{get_users_0: get_users_0} do - assert {:ok, ["real-jim", "real-joe"]} == get_users_0.() - end -end -``` +Both `apply` and `validate` accept `behaviours` as an option to declare that the module +implements certain behaviours. Ham is capable of importing the typespecs for the callbacks +if a typespec is explicitly defined. -Hammox also provides a `setup_all` friendly version of `Hammox.protect` which -leverages this pattern. Simply pass both the implementation module and the -behaviour module and you will get a map of all callbacks defined by the -behaviour as decorated implementation functions. +For example let's implement a module that does a poor job of implementing `Access` ```elixir -defmodule RealDatabaseTest do - use ExUnit.Case, async: true +defmodule CustomAccess do + @behaviour Access - setup_all do - Hammox.protect(RealDatabase, Database) - end - - test "get_users/0 returns list of users", %{get_users_0: get_users_0} do - assert {:ok, ["real-jim", "real-joe"]} == get_users_0.() - end + def fetch(_data, _key), do: :poor + def get_and_update(_data, _key, _function), do: :poor + def pop(data, key), do: :poor end -``` -Alternatively, if you're up for trading explicitness for some macro magic, -you can use `use Hammox.Protect` to locally define protected versions of -functions you're testing, as if you `import`ed the module: - -```elixir -defmodule RealDatabaseTest do - use ExUnit.Case, async: true - use Hammox.Protect, module: RealDatabase, behaviour: Database - - test "get_users/0 returns list of users" do - assert {:ok, ["real-jim", "real-joe"]} == get_users() - end -end +Ham.apply(CustomAccess.fetch([], "key"), behaviours: [Access]) +** (Ham.TypeMatchError) Returned value :poor does not match type {:ok, Access.value()} | :error. + Value :wrong does not match type {:ok, Access.value()} | :error. ``` -## Why use Hammox for my application code when I have Dialyzer? - -Dialyzer cannot detect Mox style mocks not conforming to typespec. +## Why use Ham for my application code when I have Dialyzer? -The main aim of Hammox is to enforce consistency between behaviours, mocks -and implementations. This is best achieved when both mocks and -implementations are subjected to the exact same checks. - -Dialyzer is a static analysis tool; Hammox is a dynamic contract test -provider. They operate differently and one can catch some bugs when the other -doesn't. While it is true that Hammox would be redundant given a strong, -strict, TypeScript-like type system for Elixir, Dialyzer is far from providing -that sort of coverage. +Dialyzer is a powerful static analysis tool that can uncover serious problems. +But during tests dialyzer is not as useful. Ham can be used to validate function arguments +and return values during tests even if they are randomly generated or loaded from files. ## Protocol types -A `t()` type defined on a protocol is taken by Hammox to mean "a struct +A `t()` type defined on a protocol is taken by Ham to mean "a struct implementing the given protocol". Therefore, trying to pass `:atom` for an `Enumerable.t()` will produce an error, even though the type is defined as `term()`: ```none -** (Hammox.TypeMatchError) +** (Ham.TypeMatchError) Returned value :atom does not match type Enumerable.t(). Value :atom does not implement the Enumerable protocol. ``` -## Disable protection for specific mocks - -Hammox also includes Mox as a dependency. This means that if you would like -to disable Hammox protection for a specific mock, you can simply use vanilla -Mox for that specific instance. They will interoperate without problems. - ## Limitations - For anonymous function types in typespecs, only the arity is checked. Parameter types and return types are not checked. -## Telemetry - -Hammox now includes telemetry events! See [Telemetry Guide](https://hexdocs.pm/hammox/Telemetry.html) for more information. - ## License Copyright 2019 Michał Szewczak +Copyright 2024 Eduardo Gurgel + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at diff --git a/guides/Telemetry.md b/guides/Telemetry.md deleted file mode 100644 index 20b4208..0000000 --- a/guides/Telemetry.md +++ /dev/null @@ -1,222 +0,0 @@ -# Telemetry in Hammox - When running a sufficiently large test suite using Hammox it can be important to diagnose any performance bottlenecks. - To enable telemetry reporting in hammox put this in your application config: - ```elixir - config :hammox, - enable_telemetry?: true - ``` -## Start Events - - `[:hammox, :expect, :start]` - - metadata: - - `mock`: Name of the mock/behaviour - - `function_name`: Name of the function that is being mocked - - `count`: Total expect count (defaults to 1) - - `[:hammox, :allow, :start]` - - metadata: - - `mock`: Name of the mock/behaviour - - `owner_pid`: PID of the process that owns the mock - - `allowed_via`: PID of the process that is requesting allowance of the mock - - `[:hammox, :run_expect, :start]` - - metadata: none - - `[:hammox, :check_call, :start]` - - metadata: none - - `[:hammox, :match_args, :start]` - - metadata: none - - `[:hammox, :match_return_value, :start]` - - metadata: none - - `[:hammox, :fetch_typespecs, :start]` - - metadata: - - `behaviour_name`: Name of behaviour to fetch the typespec for - - `function_name`: Name of the function to fetch the typespec for - - `arity`: Arity of the function to fetch the typespec for - - `[:hammox, :cache_put, :start]` - - metadata: - - `key`: Key of the value to put in cache - - `value`: Value to put in cache - - `[:hammox, :stub, :start]` - - metadata: - - `mock`: Name of the mock to stub - - `function_name`: Name of the function to stub on the mock - - `[:hammox, :verify_on_exit!, :start]` - - metadata: - - `context`: Context passed into `verify_on_exit!` setup function - -## Stop Events - - `[:hammox, :expect, :stop]` - - metadata: - - `mock`: Name of the mock/behaviour - - `func`: Name of the function that is being mocked - - `count`: Total expect count (defaults to 1) - - `[:hammox, :allow, :stop]` - - metadata: none - - `[:hammox, :run_expect, :stop]` - - metadata: none - - `[:hammox, :check_call, :start]` - - metadata: - - `total_specs_checked`: Count of total specs checked during verification of arguments and return values - - `[:hammox, :match_args, :stop]` - - metadata: none - - `[:hammox, :match_return_value, :stop]` - - metadata: none - - `[:hammox, :fetch_typespecs, :stop]` - - metadata: none - - `[:hammox, :cache_put, :stop]` - - metadata: none - - `[:hammox, :stub, :stop]` - - metadata: none - - `[:hammox, :verify_on_exit!, :stop]` - - metadata: none - -## Exception Events - - `[:hammox, :expect, :exception]` - - metadata: none - - `[:hammox, :allow, :exception]` - - metadata: none - - `[:hammox, :run_expect, :exception]` - - metadata: none - - `[:hammox, :check_call, :start]` - - metadata: none - - `[:hammox, :match_args, :exception]` - - metadata: none - - `[:hammox, :match_return_value, :exception]` - - metadata: none - - `[:hammox, :fetch_typespecs, :exception]` - - metadata: none - - `[:hammox, :cache_put, :exception]` - - metadata: none - - `[:hammox, :stub, :exception]` - - metadata: none - - `[:hammox, :verify_on_exit!, :exception]` - - metadata: none - -## Example Code - All supported events can be generated and attached to with the following code: - ```elixir - def build_events(event_atom) do - event_list = [ - :expect, - :allow, - :run_expect, - :check_call, - :match_args, - :match_return_value, - :fetch_typespecs, - :cache_put, - :stub, - :verify_on_exit! - ] - - Enum.map(event_list, fn event -> - [:hammox, event, event_atom] - end) - end - - ... other appplication.ex code here - - start_events = build_events(:start) - - :ok = - :telemetry.attach_many( - "hammox-start", - start_events, - &handle_event/4, - nil - ) - - stop_events = .build_events(:stop) - - :ok = - :telemetry.attach_many( - "hammox-stop", - stop_events, - &handle_event/4, - nil - ) - - exception_events = .build_events(:exception) - - :ok = - :telemetry.attach_many( - "hammox-exception", - exception_events, - &handle_event/4, - nil - ) - ``` -## Handle Event Examples - Here you can use the Hammox Telemetry to send start/end traces where applicable. This can help you understand performance bottlenecks and opportunities in your unit tests. - ```elixir - defmodule HammoxTelemetryHandler do - alias Spandex.Tracer - ... - def handle_event([:hammox, :expect, :start], measurements, metadata, _config) - when is_map(measurements) do - mock_name = Map.get(metadata, :mock) - - func_name = Map.get(metadata, :name) - - expect_count = - Map.get(metadata, :count) - |> to_string - - tags = - [] - |> tags_put(:mock, mock_name) - |> tags_put(:func_name, func_name) - |> tags_put(:expect_count, expect_count) - - system_time = get_time(measurements, :system_time) - - if Tracer.current_trace_id() do - span_string = - "#{mock_name}.#{func_name}" - |> String.trim_leading("Elixir.") - - span_string = "expect #{span_string}" - _span_context = Tracer.start_span(span_string, service: :hammox, tags: tags) - Tracer.update_span(start: system_time) - end - end - - def handle_event([:hammox, :expect, :stop], measurements, _metadata, _config) do - handle_exception(measurements) - end - - def handle_event([:hammox, :expect, :exception], measurements, _metadata, _config) do - handle_exception(measurements) - end - - defp handle_exception(measurements) do - error_message = "Exception occurred during hammox execution" - Logger.error(error_message) - - if Tracer.current_trace_id() do - current_span = Tracer.current_span([]) - Tracer.update_span_with_error(error_message, current_span) - end - - handle_stop(measurements) - end - - defp handle_stop(measurements, tags \\ []) do - duration_time = get_time(measurements, :duration) - - case Tracer.current_span([]) do - %{start: start_time} -> - completion_time = start_time + duration_time - - Tracer.update_span(tags: tags, completion_time: completion_time) - Tracer.finish_span() - - _no_current_span -> - :ok - end - end - - defp get_time(log_entry, key) do - log_entry - |> Map.get(key) - end - end - -``` diff --git a/lib/ham.ex b/lib/ham.ex new file mode 100644 index 0000000..94a0680 --- /dev/null +++ b/lib/ham.ex @@ -0,0 +1,73 @@ +defmodule Ham do + @moduledoc """ + Ham is a library for rigorous typespec checking for module functions including + typespecs from implemented behaviours + """ + + @doc """ + Apply a module function validating arguments and return value against typespecs + + iex> Ham.apply(URI, :decode, ["https%3A%2F%2Felixir-lang.org"]) + "https://elixir-lang.org" + iex> Ham.apply(URI, :char_reserved?, ["a"]) + ** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte(). + Value "a" does not match type 0..255. + """ + @spec apply(module, atom, [any], Keyword.t()) :: any + def apply(module, function_name, args, opts \\ []) do + return_value = Kernel.apply(module, function_name, args) + + validate!(module, function_name, args, return_value, opts) + + return_value + end + + @doc """ + Handy macro to apply a module function validating arguments and return value against typespecs + + iex> Ham.apply(URI.decode("https%3A%2F%2Felixir-lang.org")) + "https://elixir-lang.org" + iex> Ham.apply(URI.char_reserved?("a")) + ** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte(). + Value "a" does not match type 0..255. + """ + defmacro apply(call, opts \\ []) do + case Macro.decompose_call(call) do + :error -> + raise ArgumentError, "Invalid call" + + {module, function_name, args} -> + quote bind_quoted: [module: module, function_name: function_name, args: args, opts: opts] do + Ham.apply(module, function_name, args, opts) + end + end + end + + @doc """ + Validate arguments and return value against typespecs. + + iex> Ham.validate(URI, :char_reserved?, [?a], true) + :ok + """ + @spec validate(module, atom, [any], any, Keyword.t()) :: :ok | {:error, Ham.TypeMatchError.t()} + def validate(module, function_name, args, return_value, opts \\ []) do + behaviours = Keyword.get(opts, :behaviours, []) + + Ham.TypeChecker.validate(module, function_name, args, return_value, behaviours) + end + + @doc """ + Validate arguments and return value against typespecs + + iex> Ham.validate!(URI, :char_reserved?, ["a"], true) + ** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte(). + Value "a" does not match type 0..255. + """ + @spec validate!(module, atom, [any], any, Keyword.t()) :: :ok | no_return + def validate!(module, function_name, args, return_value, opts \\ []) do + case validate(module, function_name, args, return_value, opts) do + :ok -> :ok + {:error, error} -> raise error + end + end +end diff --git a/lib/ham/cache.ex b/lib/ham/cache.ex new file mode 100644 index 0000000..89091a9 --- /dev/null +++ b/lib/ham/cache.ex @@ -0,0 +1,23 @@ +defmodule Ham.Cache do + @moduledoc false + + def put(key, value) do + :persistent_term.put(key, value) + end + + def get(key) do + :persistent_term.get(key, nil) + end + + def fetch(key, fetcher) do + case get(key) do + nil -> + value = fetcher.() + put(key, value) + value + + value -> + value + end + end +end diff --git a/lib/ham/type_checker.ex b/lib/ham/type_checker.ex new file mode 100644 index 0000000..71cbd3e --- /dev/null +++ b/lib/ham/type_checker.ex @@ -0,0 +1,181 @@ +defmodule Ham.TypeChecker do + @moduledoc false + alias Ham.{Cache, TypeEngine, TypeMatchError, Utils} + + @doc "Validate a function call against its typespecs." + @spec validate(module, atom, [any], any, list(module)) :: :ok | {:error, TypeMatchError.t()} + def validate(module, function_name, args, return_value, behaviours \\ []) do + case fetch_typespecs(module, function_name, length(args), behaviours) do + {:ok, typespecs} -> check_call(args, return_value, typespecs) + {:error, reason} -> {:error, %TypeMatchError{reasons: [reason]}} + end + end + + defp check_call(args, return_value, typespecs) when is_list(typespecs) do + typespecs + |> Enum.reduce_while({:error, []}, fn typespec, {:error, reasons} = result -> + case match_call(args, return_value, typespec) do + :ok -> + {:halt, :ok} + + {:error, new_reasons} = new_result -> + {:cont, + if(length(reasons) >= length(new_reasons), + do: result, + else: new_result + )} + end + end) + |> case do + :ok -> :ok + {:error, []} -> :ok + {:error, reasons} -> {:error, %TypeMatchError{reasons: reasons}} + end + end + + defp match_call(args, return_value, typespec) do + args_result = match_args(args, typespec) + return_value_result = match_return_value(return_value, typespec) + + [return_value_result, args_result] + |> Enum.reduce([], fn result, acc -> + case result do + :ok -> acc + {:error, reasons} -> reasons ++ acc + end + end) + |> case do + [] -> :ok + reasons -> {:error, reasons} + end + end + + defp match_args([], _typespec), do: :ok + + # credo:disable-for-lines:24 Credo.Check.Refactor.Nesting + defp match_args(args, typespec) do + args + |> Enum.zip(0..(length(args) - 1)) + |> Enum.map(fn {arg, index} -> + arg_type = arg_typespec(typespec, index) + + case TypeEngine.match_type(arg, arg_type) do + {:error, reasons} -> + {:error, [{:arg_type_mismatch, index, arg, arg_type} | reasons]} + + :ok -> + :ok + end + end) + |> Enum.max_by(fn + {:error, reasons} -> length(reasons) + :ok -> 0 + end) + end + + defp match_return_value(return_value, typespec) do + {:type, _, :fun, [_, return_type]} = typespec + + case TypeEngine.match_type(return_value, return_type) do + {:error, reasons} -> + {:error, [{:return_type_mismatch, return_value, return_type} | reasons]} + + :ok -> + :ok + end + end + + defp fetch_typespecs(module, function_name, arity) do + with {:ok, specs} <- fetch_specs(module, :specs, &Code.Typespec.fetch_specs/1) do + {:ok, Map.get(specs, {function_name, arity}, [])} + end + end + + defp fetch_callback_typespecs(module, function_name, arity) do + with {:ok, specs} <- fetch_specs(module, :callbacks, &Code.Typespec.fetch_callbacks/1) do + {:ok, Map.get(specs, {function_name, arity}, [])} + end + end + + defp fetch_specs(module, key, fetcher) do + Cache.fetch({key, module}, fn -> + case fetcher.(module) do + {:ok, specs} -> + {:ok, build_typespecs_map(specs, module)} + + :error -> + {:error, {:module_fetch_failure, module}} + end + end) + end + + defp build_typespecs_map(specs, module) do + specs + |> Map.new(fn {{function_name, arity}, typespecs} -> + typespecs = + typespecs + |> Enum.map(&guards_to_annotated_types(&1)) + |> Enum.map(&Utils.replace_user_types(&1, module)) + + {{function_name, arity}, typespecs} + end) + end + + defp guards_to_annotated_types({:type, _, :fun, _} = typespec), do: typespec + + defp guards_to_annotated_types( + {:type, _, :bounded_fun, + [{:type, _, :fun, [{:type, _, :product, args}, return_value]}, constraints]} + ) do + type_lookup_map = + constraints + |> Enum.map(fn {:type, _, :constraint, + [{:atom, _, :is_subtype}, [{:var, _, var_name}, type]]} -> + {var_name, type} + end) + |> Enum.into(%{}) + + new_args = + Enum.map( + args, + &annotate_vars(&1, type_lookup_map) + ) + + new_return_value = annotate_vars(return_value, type_lookup_map) + + {:type, 0, :fun, [{:type, 0, :product, new_args}, new_return_value]} + end + + defp annotate_vars(type, type_lookup_map) do + Utils.type_map(type, fn + {:var, _, var_name} -> + type_for_var = Map.fetch!(type_lookup_map, var_name) + {:ann_type, 0, [{:var, 0, var_name}, type_for_var]} + + other -> + other + end) + end + + defp fetch_behaviours_typespecs(behaviours, function_name, arity) do + Enum.reduce_while(behaviours, {:ok, []}, fn behaviour, {:ok, acc} -> + case fetch_callback_typespecs(behaviour, function_name, arity) do + {:ok, callback_specs} -> {:cont, {:ok, [callback_specs | acc]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp fetch_typespecs(module, function_name, arity, behaviours) + when is_atom(module) and is_atom(function_name) and is_integer(arity) do + with {:ok, specs} <- fetch_typespecs(module, function_name, arity), + {:ok, callback_specs} <- fetch_behaviours_typespecs(behaviours, function_name, arity) do + {:ok, List.flatten([callback_specs | specs])} + end + end + + defp arg_typespec(function_typespec, arg_index) do + {:type, _, :fun, [{:type, _, :product, arg_typespecs}, _]} = function_typespec + Enum.at(arg_typespecs, arg_index) + end +end diff --git a/lib/hammox/type_engine.ex b/lib/ham/type_engine.ex similarity index 99% rename from lib/hammox/type_engine.ex rename to lib/ham/type_engine.ex index 154ff42..c3c4b6a 100644 --- a/lib/hammox/type_engine.ex +++ b/lib/ham/type_engine.ex @@ -1,8 +1,8 @@ -defmodule Hammox.TypeEngine do +defmodule Ham.TypeEngine do @moduledoc false - alias Hammox.Cache - alias Hammox.Utils + alias Ham.Cache + alias Ham.Utils @type_kinds [:type, :typep, :opaque] @@ -313,7 +313,7 @@ defmodule Hammox.TypeEngine do end def match_type(value, {:type, _, :range, [{:integer, _, low}, {:integer, _, high}]}) - when value in low..high do + when value in low..high//1 do :ok end diff --git a/lib/hammox/type_match_error.ex b/lib/ham/type_match_error.ex similarity index 85% rename from lib/hammox/type_match_error.ex rename to lib/ham/type_match_error.ex index ba72f96..7607ba5 100644 --- a/lib/hammox/type_match_error.ex +++ b/lib/ham/type_match_error.ex @@ -1,21 +1,27 @@ -defmodule Hammox.TypeMatchError do +defmodule Ham.TypeMatchError do @moduledoc """ - An error thrown when Hammox detects that values in a function call don't + An error thrown when Ham detects that values in a function call don't match types defined in typespecs. """ - defexception [:message] + defexception [:reasons] + @type t :: %__MODULE__{} - alias Hammox.Utils + alias Ham.Utils - @impl true - def exception({:error, reasons}) do - %__MODULE__{ - message: "\n" <> message_string(reasons) - } + @impl Exception + def exception({:error, reasons}), do: %__MODULE__{reasons: reasons} + + @impl Exception + def message(exception) do + message_string(exception.reasons) + end + + def translate(exception) when is_struct(exception, __MODULE__) do + Enum.map(exception.reasons, &human_reason/1) end defp human_reason({:arg_type_mismatch, index, value, type}) do - "#{Ordinal.ordinalize(index + 1)} argument value #{inspect(value)} does not match #{Ordinal.ordinalize(index + 1)} parameter's type #{type_to_string(type)}." + "#{Utils.ordinalize(index + 1)} argument value #{inspect(value)} does not match #{Utils.ordinalize(index + 1)} parameter's type #{type_to_string(type)}." end defp human_reason({:return_type_mismatch, value, type}) do @@ -23,7 +29,7 @@ defmodule Hammox.TypeMatchError do end defp human_reason({:tuple_elem_type_mismatch, index, elem, elem_type}) do - "#{Ordinal.ordinalize(index + 1)} tuple element #{inspect(elem)} does not match #{Ordinal.ordinalize(index + 1)} element type #{type_to_string(elem_type)}." + "#{Utils.ordinalize(index + 1)} tuple element #{inspect(elem)} does not match #{Utils.ordinalize(index + 1)} element type #{type_to_string(elem_type)}." end defp human_reason({:elem_type_mismatch, index, elem, elem_type}) do diff --git a/lib/hammox/utils.ex b/lib/ham/utils.ex similarity index 63% rename from lib/hammox/utils.ex rename to lib/ham/utils.ex index 209be9b..18198cb 100644 --- a/lib/hammox/utils.ex +++ b/lib/ham/utils.ex @@ -1,4 +1,4 @@ -defmodule Hammox.Utils do +defmodule Ham.Utils do @moduledoc false def module_to_string(module_name) do @@ -42,4 +42,25 @@ defmodule Hammox.Utils do def check_module_exists(module) do Code.ensure_compiled!(module) end + + # ordinalize is originally from the Ordinal package + # https://github.com/andrewhao/ordinal + # Copyright (c) 2018 Andrew Hao + @spec ordinalize(integer()) :: String.t() + def ordinalize(number) when is_integer(number) and number >= 0 do + [to_string(number), suffix(number)] + |> IO.iodata_to_binary() + end + + def ordinalize(number), do: number + + defp suffix(num) when is_integer(num) and num > 100, + do: rem(num, 100) |> suffix() + + defp suffix(num) when num in 11..13, do: "th" + defp suffix(num) when num > 10, do: rem(num, 10) |> suffix() + defp suffix(1), do: "st" + defp suffix(2), do: "nd" + defp suffix(3), do: "rd" + defp suffix(_), do: "th" end diff --git a/lib/hammox.ex b/lib/hammox.ex deleted file mode 100644 index 1f1b474..0000000 --- a/lib/hammox.ex +++ /dev/null @@ -1,632 +0,0 @@ -defmodule Hammox do - @moduledoc """ - Hammox is a library for rigorous unit testing using mocks, explicit - behaviours and contract tests. - - See the [README](readme.html) page for usage guide and examples. - - Most of the functions in this module come from - [Mox](https://hexdocs.pm/mox/Mox.html) for backwards compatibility. As of - v0.1.0, the only Hammox-specific functions are `protect/2` and `protect/3`. - """ - - alias Hammox.Cache - alias Hammox.Telemetry - alias Hammox.TypeEngine - alias Hammox.TypeMatchError - alias Hammox.Utils - - @type function_arity_pair :: {atom(), arity() | [arity()]} - - defmodule TypespecNotFoundError do - @moduledoc false - defexception [:message] - end - - @doc """ - See [Mox.allow/3](https://hexdocs.pm/mox/Mox.html#allow/3). - """ - def allow(mock, owner_pid, allowed_via) do - Telemetry.span( - [:hammox, :allow], - %{mock: mock, owner_pid: owner_pid, allowed_via: allowed_via}, - fn -> - result = Mox.allow(mock, owner_pid, allowed_via) - {result, %{}} - end - ) - end - - @doc """ - See [Mox.defmock/2](https://hexdocs.pm/mox/Mox.html#defmock/2). - """ - defdelegate defmock(name, options), to: Mox - - @doc """ - See [Mox.expect/4](https://hexdocs.pm/mox/Mox.html#expect/4). - """ - def expect(mock, function_name, n \\ 1, code) do - Telemetry.span( - [:hammox, :expect], - %{mock: mock, function_name: function_name, expect_count: n}, - fn -> - hammox_code = wrap(mock, function_name, code) - result = Mox.expect(mock, function_name, n, hammox_code) - {result, %{}} - end - ) - end - - @doc """ - See [Mox.stub/3](https://hexdocs.pm/mox/Mox.html#stub/3). - """ - def stub(mock, function_name, code) do - Telemetry.span([:hammox, :stub], %{mock: mock, function_name: function_name}, fn -> - hammox_code = wrap(mock, function_name, code) - result = Mox.stub(mock, function_name, hammox_code) - {result, %{}} - end) - end - - @doc """ - See [Mox.set_mox_from_context/1](https://hexdocs.pm/mox/Mox.html#set_mox_from_context/1). - """ - defdelegate set_mox_from_context(context), to: Mox - - @doc """ - See [Mox.set_mox_global/1](https://hexdocs.pm/mox/Mox.html#set_mox_global/1). - """ - defdelegate set_mox_global(context \\ %{}), to: Mox - - @doc """ - See [Mox.set_mox_private/1](https://hexdocs.pm/mox/Mox.html#set_mox_private/1). - """ - defdelegate set_mox_private(context \\ %{}), to: Mox - - @doc """ - See [Mox.stub_with/2](https://hexdocs.pm/mox/Mox.html#stub_with/2). - """ - defdelegate stub_with(mock, module), to: Mox - - @doc """ - See [Mox.verify!/0](https://hexdocs.pm/mox/Mox.html#verify!/0). - """ - defdelegate verify!(), to: Mox - - @doc """ - See [Mox.verify!/1](https://hexdocs.pm/mox/Mox.html#verify!/1). - """ - defdelegate verify!(mock), to: Mox - - @doc """ - See [Mox.verify_on_exit!/1](https://hexdocs.pm/mox/Mox.html#verify_on_exit!/1). - """ - def verify_on_exit!(context \\ %{}) do - Telemetry.span([:hammox, :verify_on_exit!], %{context: context}, fn -> - result = Mox.verify_on_exit!(context) - {result, %{}} - end) - end - - @doc since: "0.1.0" - @doc """ - See `protect/3`. - """ - def protect(module) - - @spec protect(module :: module()) :: %{atom() => fun()} - def protect(module) when is_atom(module) do - funs = get_funcs!(module) - protect(module, module, funs) - end - - @spec protect(mfa :: mfa()) :: fun() - def protect({module, function_name, arity}) - when is_atom(module) and is_atom(function_name) and is_integer(arity) do - protect({module, function_name, arity}, module) - end - - @doc since: "0.1.0" - @doc """ - See `protect/3`. - """ - def protect(mfa, behaviour_name) - - @spec protect(module :: module(), funs :: [function_arity_pair()]) :: %{atom() => fun()} - def protect(module, [{function, arity} | _] = funs) - when is_atom(module) and is_atom(function) and (is_integer(arity) or is_list(arity)), - do: protect(module, module, funs) - - def protect(module, [behaviour | _] = behaviour_names) - when is_atom(module) and is_atom(behaviour) do - Enum.reduce(behaviour_names, %{}, fn behaviour_name, acc -> - Map.merge(acc, protect(module, behaviour_name)) - end) - end - - @spec protect(mfa :: mfa(), behaviour_name :: module()) :: fun() - def protect({module, function_name, arity} = mfa, behaviour_name) - when is_atom(module) and is_atom(function_name) and is_integer(arity) and - is_atom(behaviour_name) do - Utils.check_module_exists(module) - Utils.check_module_exists(behaviour_name) - mfa_exist?(mfa) - - code = {module, function_name} - - typespecs = fetch_typespecs!(behaviour_name, function_name, arity) - protected(code, typespecs, arity) - end - - @spec protect(implementation_name :: module(), behaviour_name :: module()) :: %{atom() => fun()} - def protect(implementation_name, behaviour_name) - when is_atom(implementation_name) and is_atom(behaviour_name) do - funs = get_funcs!(behaviour_name) - protect(implementation_name, behaviour_name, funs) - end - - @doc since: "0.1.0" - @doc """ - Decorates functions with Hammox checks based on given behaviour. - - ## Basic usage - - When passed an MFA tuple representing the function you'd like to protect, - and a behaviour containing a callback for the function, it returns a new - anonymous function that raises `Hammox.TypeMatchError` when called - incorrectly or when it returns an incorrect value. - - Example: - ```elixir - defmodule Calculator do - @callback add(integer(), integer()) :: integer() - end - - defmodule TestCalculator do - def add(a, b), do: a + b - end - - add_2 = Hammox.protect({TestCalculator, :add, 2}, Calculator) - - add_2.(1.5, 2.5) # throws Hammox.TypeMatchError - ``` - - ## Batch usage - - You can decorate all functions defined by a given behaviour by passing an - implementation module and a behaviour module. Optionally, you can pass an - explicit list of functions as the third argument. - - The returned map is useful as the return value for a test setup callback to - set test context for all tests to use. - - Example: - ```elixir - defmodule Calculator do - @callback add(integer(), integer()) :: integer() - @callback add(integer(), integer(), integer()) :: integer() - @callback add(integer(), integer(), integer(), integer()) :: integer() - @callback multiply(integer(), integer()) :: integer() - end - - defmodule TestCalculator do - def add(a, b), do: a + b - def add(a, b, c), do: a + b + c - def add(a, b, c, d), do: a + b + c + d - def multiply(a, b), do: a * b - end - - %{ - add_2: add_2, - add_3: add_3, - add_4: add_4 - multiply_2: multiply_2 - } = Hammox.protect(TestCalculator, Calculator) - - # optionally - %{ - add_2: add_2, - add_3: add_3, - multiply_2: multiply_2 - } = Hammox.protect(TestCalculator, Calculator, add: [2, 3], multiply: 2) - ``` - - ## Batch usage for multiple behviours - - You can decorate all functions defined by any number of behaviours by passing an - implementation module and a list of behaviour modules. - - The returned map is useful as the return value for a test setup callback to - set test context for all tests to use. - - Example: - ```elixir - defmodule Calculator do - @callback add(integer(), integer()) :: integer() - @callback multiply(integer(), integer()) :: integer() - end - - defmodule AdditionalCalculator do - @callback subtract(integer(), integer()) :: integer() - end - - defmodule TestCalculator do - def add(a, b), do: a + b - def multiply(a, b), do: a * b - def subtract(a, b), do: a - b - end - - %{ - add_2: add_2, - multiply_2: multiply_2 - subtract_2: subtract_2 - } = Hammox.protect(TestCalculator, [Calculator, AdditionalCalculator]) - ``` - - ## Behaviour-implementation shortcuts - - Often, there exists one "default" implementation for a behaviour. A common - practice is then to define both the callbacks and the implementations in - one module. For these behaviour-implementation modules, Hammox provides - shortucts that only require one module. - - Example: - - ```elixir - defmodule Calculator do - @callback add(integer(), integer()) :: integer() - def add(a, b), do: a + b - end - - Hammox.protect({Calculator, :add, 2}) - # is equivalent to - Hammox.protect({Calculator, :add, 2}, Calculator) - - Hammox.protect(Calculator, add: 2) - # is equivalent to - Hammox.protect(Calculator, Calculator, add: 2) - - Hammox.protect(Calculator) - # is equivalent to - Hammox.protect(Calculator, Calculator) - ``` - """ - @spec protect( - module :: module(), - behaviour_name :: module(), - funs :: [function_arity_pair()] - ) :: - %{atom() => fun()} - def protect(module, behaviour_name, funs) - when is_atom(module) and is_atom(behaviour_name) and is_list(funs) do - funs - |> Enum.flat_map(fn {function_name, arity_or_arities} -> - arity_or_arities - |> List.wrap() - |> Enum.map(fn arity -> - key = - function_name - |> Atom.to_string() - |> Kernel.<>("_#{arity}") - |> String.to_atom() - - value = protect({module, function_name, arity}, behaviour_name) - {key, value} - end) - end) - |> Enum.into(%{}) - end - - defp wrap(mock, name, code) do - arity = :erlang.fun_info(code)[:arity] - - case fetch_typespecs_for_mock(mock, name, arity) do - # This is really an error case where we're trying to mock a function - # that does not exist in the behaviour. Mox will flag it better though - # so just let it pass through. - [] -> code - typespecs -> protected(code, typespecs, arity) - end - end - - defp protected(code, typespecs, 0) do - fn -> - protected_code(code, typespecs, []) - end - end - - defp protected(code, typespecs, 1) do - fn arg1 -> - protected_code(code, typespecs, [arg1]) - end - end - - defp protected(code, typespecs, 2) do - fn arg1, arg2 -> - protected_code(code, typespecs, [arg1, arg2]) - end - end - - defp protected(code, typespecs, 3) do - fn arg1, arg2, arg3 -> - protected_code(code, typespecs, [arg1, arg2, arg3]) - end - end - - defp protected(code, typespecs, 4) do - fn arg1, arg2, arg3, arg4 -> - protected_code(code, typespecs, [arg1, arg2, arg3, arg4]) - end - end - - defp protected(code, typespecs, 5) do - fn arg1, arg2, arg3, arg4, arg5 -> - protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5]) - end - end - - defp protected(code, typespecs, 6) do - fn arg1, arg2, arg3, arg4, arg5, arg6 -> - protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6]) - end - end - - defp protected(code, typespecs, 7) do - fn arg1, arg2, arg3, arg4, arg5, arg6, arg7 -> - protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6, arg7]) - end - end - - defp protected(code, typespecs, 8) do - fn arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 -> - protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8]) - end - end - - defp protected(code, typespecs, 9) do - fn arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9 -> - protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9]) - end - end - - defp protected(_code, _typespec, arity) when arity > 9 do - raise "Hammox only supports protecting functions with arity up to 9. Why do you need over 9 parameters anyway?" - end - - defp protected_code(code, typespecs, args) do - return_value = - Telemetry.span([:hammox, :run_expect], %{}, fn -> - return_value = - case code do - {module, function_name} -> apply(module, function_name, args) - anonymous when is_function(anonymous) -> apply(anonymous, args) - end - - {return_value, %{}} - end) - - check_call(args, return_value, typespecs) - - return_value - end - - # credo:disable-for-lines:30 Credo.Check.Refactor.Nesting - defp check_call(args, return_value, typespecs) when is_list(typespecs) do - match_call_result = - Telemetry.span([:hammox, :check_call], %{}, fn -> - {result, check_call_count} = - typespecs - |> Enum.reduce_while({{:error, []}, 0}, fn typespec, - {{:error, reasons} = result, counter} -> - counter = counter + 1 - - case match_call(args, return_value, typespec) do - :ok -> - {:halt, {:ok, counter}} - - {:error, new_reasons} = new_result -> - {:cont, - if(length(reasons) >= length(new_reasons), - do: {result, counter}, - else: {new_result, counter} - )} - end - end) - - {result, %{total_specs_checked: check_call_count}} - end) - - case match_call_result do - {:error, _} = error -> raise TypeMatchError, error - :ok -> :ok - end - end - - defp match_call(args, return_value, typespec) do - # Even though the last clause is redundant, it reads better this way. - # credo:disable-for-next-line Credo.Check.Refactor.RedundantWithClauseResult - with :ok <- match_args(args, typespec), - :ok <- match_return_value(return_value, typespec) do - :ok - end - end - - defp match_args([], _typespec) do - :ok - end - - # credo:disable-for-lines:24 Credo.Check.Refactor.Nesting - defp match_args(args, typespec) do - Telemetry.span([:hammox, :match_args], %{}, fn -> - result = - args - |> Enum.zip(0..(length(args) - 1)) - |> Enum.map(fn {arg, index} -> - arg_type = arg_typespec(typespec, index) - - case TypeEngine.match_type(arg, arg_type) do - {:error, reasons} -> - {:error, [{:arg_type_mismatch, index, arg, arg_type} | reasons]} - - :ok -> - :ok - end - end) - |> Enum.max_by(fn - {:error, reasons} -> length(reasons) - :ok -> 0 - end) - - {result, %{}} - end) - end - - defp match_return_value(return_value, typespec) do - Telemetry.span([:hammox, :match_return_value], %{}, fn -> - {:type, _, :fun, [_, return_type]} = typespec - - result = - case TypeEngine.match_type(return_value, return_type) do - {:error, reasons} -> - {:error, [{:return_type_mismatch, return_value, return_type} | reasons]} - - :ok -> - :ok - end - - {result, %{}} - end) - end - - defp fetch_typespecs!(behaviour_name, function_name, arity) do - case fetch_typespecs(behaviour_name, function_name, arity) do - [] -> - raise TypespecNotFoundError, - message: - "Could not find typespec for #{Utils.module_to_string(behaviour_name)}.#{function_name}/#{arity}." - - typespecs -> - typespecs - end - end - - defp fetch_typespecs(behaviour_name, function_name, arity) do - Telemetry.span( - [:hammox, :fetch_typespecs], - %{behaviour_name: behaviour_name, function_name: function_name, arity: arity}, - fn -> - cache_key = {:typespecs, {behaviour_name, function_name, arity}} - - result = - case Cache.get(cache_key) do - nil -> - typespecs = do_fetch_typespecs(behaviour_name, function_name, arity) - Cache.put(cache_key, typespecs) - typespecs - - typespecs -> - typespecs - end - - {result, %{}} - end - ) - end - - defp do_fetch_typespecs(behaviour_module, function_name, arity) do - callbacks = fetch_callbacks(behaviour_module) - - callbacks - |> Enum.find_value([], fn - {{^function_name, ^arity}, typespecs} -> typespecs - _ -> false - end) - |> Enum.map(&guards_to_annotated_types(&1)) - |> Enum.map(&Utils.replace_user_types(&1, behaviour_module)) - end - - defp guards_to_annotated_types({:type, _, :fun, _} = typespec), do: typespec - - defp guards_to_annotated_types( - {:type, _, :bounded_fun, - [{:type, _, :fun, [{:type, _, :product, args}, return_value]}, constraints]} - ) do - type_lookup_map = - constraints - |> Enum.map(fn {:type, _, :constraint, - [{:atom, _, :is_subtype}, [{:var, _, var_name}, type]]} -> - {var_name, type} - end) - |> Enum.into(%{}) - - new_args = - Enum.map( - args, - &annotate_vars(&1, type_lookup_map) - ) - - new_return_value = annotate_vars(return_value, type_lookup_map) - - {:type, 0, :fun, [{:type, 0, :product, new_args}, new_return_value]} - end - - defp annotate_vars(type, type_lookup_map) do - Utils.type_map(type, fn - {:var, _, var_name} -> - type_for_var = Map.fetch!(type_lookup_map, var_name) - {:ann_type, 0, [{:var, 0, var_name}, type_for_var]} - - other -> - other - end) - end - - defp fetch_callbacks(behaviour_module) do - case Cache.get({:callbacks, behaviour_module}) do - nil -> - {:ok, callbacks} = Code.Typespec.fetch_callbacks(behaviour_module) - Cache.put({:callbacks, behaviour_module}, callbacks) - callbacks - - callbacks -> - callbacks - end - end - - defp fetch_typespecs_for_mock(mock_name, function_name, arity) - when is_atom(mock_name) and is_atom(function_name) and is_integer(arity) do - mock_name.__mock_for__() - |> Enum.map(fn behaviour -> - fetch_typespecs(behaviour, function_name, arity) - end) - |> List.flatten() - end - - defp arg_typespec(function_typespec, arg_index) do - {:type, _, :fun, [{:type, _, :product, arg_typespecs}, _]} = function_typespec - Enum.at(arg_typespecs, arg_index) - end - - defp mfa_exist?({module, function_name, arity}) do - case function_exported?(module, function_name, arity) do - true -> - true - - _ -> - raise(ArgumentError, - message: - "Could not find function #{Utils.module_to_string(module)}.#{function_name}/#{arity}." - ) - end - end - - defp get_funcs!(module) do - Utils.check_module_exists(module) - - module - |> fetch_callbacks() - |> Enum.map(fn {callback, _typespecs} -> - callback - end) - end -end diff --git a/lib/hammox/cache.ex b/lib/hammox/cache.ex deleted file mode 100644 index e2b9f1b..0000000 --- a/lib/hammox/cache.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Hammox.Cache do - @moduledoc false - - alias Hammox.Telemetry - - def put(key, value) do - Telemetry.span( - [:hammox, :cache_put], - %{key: key, value: value}, - fn -> - result = :persistent_term.put(key, value) - {result, %{}} - end - ) - end - - def get(key) do - # telemetry for this function is FAR too expensive (1000x slower) - :persistent_term.get(key, nil) - end -end diff --git a/lib/hammox/protect.ex b/lib/hammox/protect.ex deleted file mode 100644 index f1b48c0..0000000 --- a/lib/hammox/protect.ex +++ /dev/null @@ -1,160 +0,0 @@ -defmodule Hammox.Protect do - @moduledoc """ - A `use`able module simplifying protecting functions with Hammox. - - The explicit way is to use `Hammox.protect/3` and friends to generate - protected versions of functions as anonymous functions. In tests, the most - convenient way is to generate them once in a setup hook and then resolve - them from test context. However, this can get quite verbose. - - If you're willing to trade explicitness for some macro magic, doing `use - Hammox.Protect` in your test module will define functions from the module - you want to protect in it. The effect is similar to `import`ing the module - you're testing, but with added benefit of the functions being protected. - - `use Hammox.Protect` supports these options: - - `:module` (required) — the module you'd like to protect (usually the one - you're testing in the test module). Equivalent to the first parameter of - `Hammox.protect/3` in batch usage. - - `:behaviour` — the behaviour module you'd like to protect the - implementation module with. Can be skipped if `:module` and `:behaviour` - are the same module. Equivalent to the second parameter of - `Hammox.protect/3` in batch usage. - - `:funs` — An optional explicit list of functions you'd like to protect. - Equivalent to the third parameter of `Hammox.protect/3` in batch usage. - - Additionally multiple `behaviour` and `funs` options can be provided for - modules that implement multiple behaviours - - note: the `funs` options are optional but specific to the `behaviour` that - precedes them - - ``` - use Hammox.Protect, - module: Hammox.Test.MultiBehaviourImplementation, - behaviour: Hammox.Test.SmallBehaviour, - # the `funs` opt below effects the funs protected from `SmallBehaviour` - funs: [foo: 0, other_foo: 1], - behaviour: Hammox.Test.AdditionalBehaviour - # with no `funs` pt provided after `AdditionalBehaviour`, all callbacks - # will be protected - ```` - """ - alias Hammox.Utils - - defmacro __using__(opts) do - opts_block = - quote do - mod_behaviour_funs = Hammox.Protect.extract_opts!(unquote(opts)) - end - - funs_block = - quote unquote: false do - for {module, behaviour, funs} <- mod_behaviour_funs, {name, arity} <- funs do - def unquote(name)( - unquote_splicing( - Enum.map( - case arity do - 0 -> [] - arity -> 1..arity - end, - &Macro.var(:"arg#{&1}", __MODULE__) - ) - ) - ) do - protected_fun = - Hammox.Protect.protect( - {unquote(module), unquote(name), unquote(arity)}, - unquote(behaviour) - ) - - apply( - protected_fun, - unquote( - Enum.map( - case arity do - 0 -> [] - arity -> 1..arity - end, - &Macro.var(:"arg#{&1}", __MODULE__) - ) - ) - ) - end - end - end - - quote do - unquote(opts_block) - unquote(funs_block) - end - end - - @doc false - def extract_opts!(opts) do - module = Keyword.get(opts, :module) - - if is_nil(module) do - raise ArgumentError, - message: """ - Please specify :module to protect with Hammox.Protect. - Example: - - use Hammox.Protect, module: ModuleToProtect - - """ - end - - mods_and_funs = - opts - |> Keyword.take([:behaviour, :funs]) - |> case do - # just the module in opts - [] -> - [{module, get_funs!(module)}] - - # module and funs in opts - [{:funs, funs}] -> - [{module, funs}] - - # module multiple behaviours with or without funs - behaviours_and_maybe_funs -> - reduce_opts_to_behaviours_and_funs({behaviours_and_maybe_funs, []}) - end - - mods_and_funs - |> Enum.map(fn {module_with_callbacks, funs} -> - if funs == [] do - raise ArgumentError, - message: - "The module #{inspect(module_with_callbacks)} does not contain any callbacks. Please use a behaviour with at least one callback." - end - - {module, module_with_callbacks, funs} - end) - end - - defp reduce_opts_to_behaviours_and_funs({[], acc}) do - acc - end - - defp reduce_opts_to_behaviours_and_funs({[{:behaviour, behaviour}, {:funs, funs} | rest], acc}) do - reduce_opts_to_behaviours_and_funs({rest, [{behaviour, funs} | acc]}) - end - - defp reduce_opts_to_behaviours_and_funs({[{:behaviour, behaviour} | rest], acc}) do - reduce_opts_to_behaviours_and_funs({rest, [{behaviour, get_funs!(behaviour)} | acc]}) - end - - @doc false - def protect(mfa, nil), do: Hammox.protect(mfa) - def protect(mfa, behaviour), do: Hammox.protect(mfa, behaviour) - - defp get_funs!(module) do - Utils.check_module_exists(module) - {:ok, callbacks} = Code.Typespec.fetch_callbacks(module) - - Enum.map(callbacks, fn {callback, _typespecs} -> - callback - end) - end -end diff --git a/lib/hammox/telemetry.ex b/lib/hammox/telemetry.ex deleted file mode 100644 index 820535a..0000000 --- a/lib/hammox/telemetry.ex +++ /dev/null @@ -1,58 +0,0 @@ -defmodule Hammox.Telemetry.Behaviour do - @moduledoc false - @callback span(list(), map(), function()) :: :ok -end - -defmodule Hammox.Telemetry.NoOp do - @moduledoc false - @behaviour Hammox.Telemetry.Behaviour - @impl Hammox.Telemetry.Behaviour - def span(_telemetry_tags, _telemetry_metadata, func_to_wrap) do - # need to unwrap the result since :telemetry.span needs {result, %{}} as a return value - {result, _ignore} = func_to_wrap.() - result - end -end - -defmodule Hammox.Telemetry.TelemetryEnabled do - @moduledoc false - @behaviour Hammox.Telemetry.Behaviour - @impl Hammox.Telemetry.Behaviour - def span(telemetry_tags, telemetry_metadata, func_to_wrap) do - :telemetry.span(telemetry_tags, telemetry_metadata, func_to_wrap) - end -end - -defmodule Hammox.Telemetry do - @moduledoc """ - This module wraps :telemetry so users of this library can opt in/out of telemetry. - Telemetry is disabled by default and will use our NoOp client. - To enable telemetry set this in your application config: - `config :hammox, enable_telemetry?: true` - """ - - def telemetry_module do - case Application.fetch_env(:hammox, :enable_telemetry?) do - :error -> - # default to NoOp implementation - Hammox.Telemetry.NoOp - - {:ok, enabled?} -> - if enabled? do - # if enable_telemetry? is true use the TelemetryEnabled implementation - Hammox.Telemetry.TelemetryEnabled - else - # if enable_telemetry? is false use NoOp implementation - Hammox.Telemetry.NoOp - end - end - end - - @behaviour Hammox.Telemetry.Behaviour - @impl Hammox.Telemetry.Behaviour - def span(telemetry_tags, telemetry_metadata, func_to_wrap) do - # telemetry_module().span(telemetry_tags, telemetry_metadata, func_to_wrap) - tm = telemetry_module() - tm.span(telemetry_tags, telemetry_metadata, func_to_wrap) - end -end diff --git a/lib/hammox/telemetry_test.exs b/lib/hammox/telemetry_test.exs deleted file mode 100644 index b0001ee..0000000 --- a/lib/hammox/telemetry_test.exs +++ /dev/null @@ -1,153 +0,0 @@ -defmodule HammoxTest do - # false because we set application state - use ExUnit.Case, async: false - - import Hammox - - defmock(TestMock, for: Hammox.Test.Behaviour) - - describe "works with telemetry disabled as a default" do - setup do - Application.delete_env(:hammox, :enable_telemetry?) - end - - test "should default to NoOp module" do - assert Hammox.Telemetry.NoOp == Hammox.Telemetry.telemetry_module() - end - - test "decorate all functions inside the module" do - assert %{other_foo_0: _, other_foo_1: _, foo_0: _} = - Hammox.protect(Hammox.Test.BehaviourImplementation) - end - end - - describe "works with telemetry enabled" do - setup do - Application.put_env(:hammox, :enable_telemetry?, true) - - on_exit(fn -> - Application.delete_env(:hammox, :enable_telemetry?) - end) - end - - test "should use telemetry client" do - assert Hammox.Telemetry.TelemetryEnabled == Hammox.Telemetry.telemetry_module() - end - - test "decorate all functions inside the module" do - assert %{other_foo_0: _, other_foo_1: _, foo_0: _} = - Hammox.protect(Hammox.Test.BehaviourImplementation) - end - - test "decorates the function designated by the MFA tuple" do - fun = Hammox.protect({Hammox.Test.BehaviourImplementation, :foo, 0}) - assert_raise(Hammox.TypeMatchError, fn -> fun.() end) - end - - test "returns function protected from contract errors" do - fun = Hammox.protect({Hammox.Test.SmallImplementation, :foo, 0}, Hammox.Test.SmallBehaviour) - assert_raise(Hammox.TypeMatchError, fn -> fun.() end) - end - - test "throws when typespec does not exist" do - assert_raise(Hammox.TypespecNotFoundError, fn -> - Hammox.protect( - {Hammox.Test.SmallImplementation, :nospec_fun, 0}, - Hammox.Test.SmallBehaviour - ) - end) - end - - test "throws when behaviour module does not exist" do - assert_raise(ArgumentError, fn -> - Hammox.protect( - {Hammox.Test.SmallImplementation, :foo, 0}, - NotExistModule - ) - end) - end - - test "throws when implementation module does not exist" do - assert_raise(ArgumentError, fn -> - Hammox.protect( - {NotExistModule, :foo, 0}, - Hammox.Test.SmallBehaviour - ) - end) - end - - test "throws when implementation function does not exist" do - assert_raise(ArgumentError, fn -> - Hammox.protect( - {Hammox.Test.SmallImplementation, :nonexistent_fun, 0}, - Hammox.Test.SmallBehaviour - ) - end) - end - end - - describe "works with telemetry disabled" do - setup do - Application.put_env(:hammox, :enable_telemetry?, false) - - on_exit(fn -> - Application.delete_env(:hammox, :enable_telemetry?) - end) - end - - test "should use NoOp module" do - assert Hammox.Telemetry.NoOp == Hammox.Telemetry.telemetry_module() - end - - test "decorate all functions inside the module" do - assert %{other_foo_0: _, other_foo_1: _, foo_0: _} = - Hammox.protect(Hammox.Test.BehaviourImplementation) - end - - test "decorates the function designated by the MFA tuple" do - fun = Hammox.protect({Hammox.Test.BehaviourImplementation, :foo, 0}) - assert_raise(Hammox.TypeMatchError, fn -> fun.() end) - end - - test "returns function protected from contract errors" do - fun = Hammox.protect({Hammox.Test.SmallImplementation, :foo, 0}, Hammox.Test.SmallBehaviour) - assert_raise(Hammox.TypeMatchError, fn -> fun.() end) - end - - test "throws when typespec does not exist" do - assert_raise(Hammox.TypespecNotFoundError, fn -> - Hammox.protect( - {Hammox.Test.SmallImplementation, :nospec_fun, 0}, - Hammox.Test.SmallBehaviour - ) - end) - end - - test "throws when behaviour module does not exist" do - assert_raise(ArgumentError, fn -> - Hammox.protect( - {Hammox.Test.SmallImplementation, :foo, 0}, - NotExistModule - ) - end) - end - - test "throws when implementation module does not exist" do - assert_raise(ArgumentError, fn -> - Hammox.protect( - {NotExistModule, :foo, 0}, - Hammox.Test.SmallBehaviour - ) - end) - end - - test "throws when implementation function does not exist" do - assert_raise(ArgumentError, fn -> - Hammox.protect( - {Hammox.Test.SmallImplementation, :nonexistent_fun, 0}, - Hammox.Test.SmallBehaviour - ) - end) - end - end -end diff --git a/mix.exs b/mix.exs index b15cff7..50233de 100644 --- a/mix.exs +++ b/mix.exs @@ -1,30 +1,28 @@ -defmodule Hammox.MixProject do +defmodule Ham.MixProject do use Mix.Project - @source_url "https://github.com/msz/hammox" - @version "0.7.0" + @source_url "https://github.com/edgurgel/ham" + @version "0.1.0" def project do [ - app: :hammox, + app: :ham, version: @version, - elixir: "~> 1.7", + elixir: "~> 1.16", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), aliases: aliases(), # Docs - name: "Hammox", + name: "Ham", docs: docs(), package: package() ] end def application do - [ - extra_applications: [:logger] - ] + [] end defp aliases do @@ -35,25 +33,23 @@ defmodule Hammox.MixProject do defp deps do [ - {:mox, "~> 1.0"}, - {:ordinal, "~> 0.1"}, - {:telemetry, "~> 1.0"}, - {:ex_doc, "~> 0.21", only: :dev, runtime: false}, - {:credo, "~> 1.6", only: [:dev, :test], runtime: false} + {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, + {:ex_doc, "~> 0.21", only: :dev, runtime: false} ] end defp package do [ - description: "Automated contract testing for functions and mocks.", + description: "Runtime Type checking", licenses: ["Apache-2.0"], maintainers: [ - "Michał Szewczak" + "Eduardo Gurgel" ], files: ["lib", "mix.exs", "LICENSE", "README.md"], links: %{ "GitHub" => @source_url, - "Mox" => "https://hex.pm/packages/mox" + "Mox" => "https://hex.pm/packages/ham" } ] end diff --git a/mix.lock b/mix.lock index 9e8bbb3..b7ea406 100644 --- a/mix.lock +++ b/mix.lock @@ -1,15 +1,17 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "credo": {:hex, :credo, "1.6.6", "f51f8d45db1af3b2e2f7bee3e6d3c871737bda4a91bff00c5eec276517d1a19c", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "625520ce0984ee0f9f1f198165cd46fa73c1e59a17ebc520038b8fce056a5bdc"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, - "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, - "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, - "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, + "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, + "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, + "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "ordinal": {:hex, :ordinal, "0.2.0", "d3eda0cb04ee1f0ca0aae37bf2cf56c28adce345fe56a75659031b6068275191", [:mix], [], "hexpm", "defca8f10dee9f03a090ed929a595303252700a9a73096b6f2f8d88341690d65"}, - "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, } diff --git a/test/hammox_test.exs b/test/ham/type_checker_test.exs similarity index 77% rename from test/hammox_test.exs rename to test/ham/type_checker_test.exs index e780fca..fd7178d 100644 --- a/test/hammox_test.exs +++ b/test/ham/type_checker_test.exs @@ -1,100 +1,52 @@ -defmodule HammoxTest do +defmodule Ham.TypeCheckerTest do use ExUnit.Case, async: true - - import Hammox - - defmock(TestMock, for: Hammox.Test.Behaviour) - - describe "protect/1" do - test "decorate all functions inside the module" do - assert %{other_foo_0: _, other_foo_1: _, foo_0: _} = - Hammox.protect(Hammox.Test.BehaviourImplementation) - end - - test "decorates the function designated by the MFA tuple" do - fun = Hammox.protect({Hammox.Test.BehaviourImplementation, :foo, 0}) - assert_raise(Hammox.TypeMatchError, fn -> fun.() end) - end - end - - describe "protect/2" do - test "returns function protected from contract errors" do - fun = Hammox.protect({Hammox.Test.SmallImplementation, :foo, 0}, Hammox.Test.SmallBehaviour) - assert_raise(Hammox.TypeMatchError, fn -> fun.() end) - end - - test "throws when typespec does not exist" do - assert_raise(Hammox.TypespecNotFoundError, fn -> - Hammox.protect( - {Hammox.Test.SmallImplementation, :nospec_fun, 0}, - Hammox.Test.SmallBehaviour - ) - end) - end - - test "throws when behaviour module does not exist" do - assert_raise(ArgumentError, fn -> - Hammox.protect( - {Hammox.Test.SmallImplementation, :foo, 0}, - NotExistModule - ) - end) + alias Ham.TypeChecker + + alias Ham.Test.{ + AdditionalBehaviour, + Behaviour, + Impl, + MultiBehaviourImplementation, + SmallBehaviour, + TestModule + } + + describe "validate/4" do + test "valid type" do + assert :ok = TypeChecker.validate(TestModule, :sum, [1, 2], 3) + end + + test "valid behaviour type" do + assert :ok = + TypeChecker.validate(MultiBehaviourImplementation, :other_foo, [1], 3, [ + SmallBehaviour + ]) end - test "throws when implementation module does not exist" do - assert_raise(ArgumentError, fn -> - Hammox.protect( - {NotExistModule, :foo, 0}, - Hammox.Test.SmallBehaviour - ) - end) - end + test "invalid type" do + assert {:error, error} = TypeChecker.validate(TestModule, :sum, ["a", 2], :not_ok) - test "throws when implementation function does not exist" do - assert_raise(ArgumentError, fn -> - Hammox.protect( - {Hammox.Test.SmallImplementation, :nonexistent_fun, 0}, - Hammox.Test.SmallBehaviour - ) - end) + assert Ham.TypeMatchError.translate(error) == [ + "1st argument value \"a\" does not match 1st parameter's type number().", + "Value \"a\" does not match type integer() | float().", + "Returned value :not_ok does not match type number().", + "Value :not_ok does not match type integer() | float()." + ] end - test "decorate multiple functions inside behaviour-implementation module" do - assert %{foo_0: _, other_foo_1: _} = - Hammox.protect(Hammox.Test.BehaviourImplementation, - foo: 0, - other_foo: 1 - ) - end + test "unknown type" do + assert {:error, error} = TypeChecker.validate(EstModule, :unknown, ["a", 2], :not_ok) - test "decorate all functions" do - assert %{foo_0: _, other_foo_0: _, other_foo_1: _} = - Hammox.protect(Hammox.Test.SmallImplementation, Hammox.Test.SmallBehaviour) + assert Ham.TypeMatchError.translate(error) == ["Could not load module EstModule."] end - test "decorate all functions from multiple behaviours" do - assert %{foo_0: _, other_foo_0: _, other_foo_1: _, additional_foo_0: _} = - Hammox.protect(Hammox.Test.MultiBehaviourImplementation, [ - Hammox.Test.SmallBehaviour, - Hammox.Test.AdditionalBehaviour + test "unknown behaviour type" do + assert {:error, error} = + TypeChecker.validate(MultiBehaviourImplementation, :other_foo, [1], 3, [ + UnknownBehaviour ]) - end - end - - describe "protect/3" do - test "returns setup_all friendly map" do - assert %{foo_0: _, other_foo_1: _} = - Hammox.protect(Hammox.Test.SmallImplementation, Hammox.Test.SmallBehaviour, - foo: 0, - other_foo: 1 - ) - end - test "works with arity arrays" do - assert %{other_foo_0: _, other_foo_1: _} = - Hammox.protect(Hammox.Test.SmallImplementation, Hammox.Test.SmallBehaviour, - other_foo: [0, 1] - ) + assert Ham.TypeMatchError.translate(error) == ["Could not load module UnknownBehaviour."] end end @@ -150,7 +102,7 @@ defmodule HammoxTest do describe "pid()" do test "pass" do - assert_pass(:foo_pid, spawn(fn -> nil end)) + assert_pass(:foo_pid, self()) end test "fail" do @@ -181,7 +133,7 @@ defmodule HammoxTest do describe "struct()" do test "pass" do - assert_pass(:foo_struct, %Hammox.Test.Struct{foo: :bar}) + assert_pass(:foo_struct, %Ham.Test.Struct{foo: :bar}) end test "fail" do @@ -675,15 +627,15 @@ defmodule HammoxTest do end test "fail different struct" do - assert_fail(:foo_struct_literal, %Hammox.Test.OtherStruct{}) + assert_fail(:foo_struct_literal, %Ham.Test.OtherStruct{}) end test "pass default struct" do - assert_pass(:foo_struct_literal, %Hammox.Test.Struct{}) + assert_pass(:foo_struct_literal, %Ham.Test.Struct{}) end test "pass struct with fields" do - assert_pass(:foo_struct_literal, %Hammox.Test.Struct{foo: 1}) + assert_pass(:foo_struct_literal, %Ham.Test.Struct{foo: 1}) end end @@ -693,15 +645,15 @@ defmodule HammoxTest do end test "fail default struct" do - assert_fail(:foo_struct_fields_literal, %Hammox.Test.Struct{}) + assert_fail(:foo_struct_fields_literal, %Ham.Test.Struct{}) end test "pass struct with correct fields" do - assert_pass(:foo_struct_fields_literal, %Hammox.Test.Struct{foo: 1}) + assert_pass(:foo_struct_fields_literal, %Ham.Test.Struct{foo: 1}) end test "fail struct with incorrect fields" do - assert_fail(:foo_struct_fields_literal, %Hammox.Test.Struct{foo: "bar"}) + assert_fail(:foo_struct_fields_literal, %Ham.Test.Struct{foo: "bar"}) end end @@ -885,7 +837,7 @@ defmodule HammoxTest do describe "identifier()" do test "pass pid" do - assert_pass(:foo_identifier, spawn(fn -> nil end)) + assert_pass(:foo_identifier, self()) end test "pass port" do @@ -1126,29 +1078,24 @@ defmodule HammoxTest do end end - describe "user type defined in behaviour" do + describe "user type defined in module" do test "pass" do - assert_pass(:foo_behaviour_user_type, :foo_type) + assert_pass(:foo_module_user_type, :foo_type) end test "fail" do - assert_fail(:foo_behaviour_user_type, :other_type) + assert_fail(:foo_module_user_type, :other_type) end end describe "user type as annotated param" do test "pass" do - TestMock |> expect(:foo_ann_type_user_type, fn _ -> :ok end) - assert :ok == TestMock.foo_ann_type_user_type(:foo_type) + assert_pass(:foo_ann_type_user_type, [:foo_type], :ok) end test "fail" do - TestMock |> expect(:foo_ann_type_user_type, fn _ -> :ok end) - - assert_raise( - Hammox.TypeMatchError, - fn -> TestMock.foo_ann_type_user_type(:other_type) end - ) + assert_fail(:foo_ann_type_user_type, [:foo_type_wrong], :ok) + assert_fail(:foo_ann_type_user_type, [:foo_type], :not_ok) end end @@ -1184,75 +1131,64 @@ defmodule HammoxTest do describe "arg type checking" do test "no args pass" do - TestMock |> expect(:foo_no_arg, fn -> :ok end) - assert :ok == TestMock.foo_no_arg() + assert_pass(:foo_no_arg, :ok) end test "unnamed arg pass" do - TestMock |> expect(:foo_unnamed_arg, fn _arg -> :ok end) - assert :ok == TestMock.foo_unnamed_arg(:bar) + assert_pass(:foo_unnamed_arg, [:bar], :ok) end test "unnamed arg fail" do - TestMock |> expect(:foo_unnamed_arg, fn _arg -> :ok end) - - assert_raise( - Hammox.TypeMatchError, - ~r/1st argument value "bar" does not match 1st parameter's type atom()./, - fn -> TestMock.foo_unnamed_arg("bar") end + assert_fail( + :foo_unnamed_arg, + ["bar"], + :ok, + ~r/1st argument value "bar" does not match 1st parameter's type atom()./ ) end test "named arg pass" do - TestMock |> expect(:foo_named_arg, fn _arg -> :ok end) - assert :ok == TestMock.foo_named_arg(:bar) + assert_pass(:foo_named_arg, [:bar], :ok) end test "named arg fail" do - TestMock |> expect(:foo_named_arg, fn _arg -> :ok end) - - assert_raise( - Hammox.TypeMatchError, - ~r/1st argument value "bar" does not match 1st parameter's type atom\(\) \("arg1"\)/, - fn -> TestMock.foo_named_arg("bar") end + assert_fail( + :foo_named_arg, + ["bar"], + :ok, + ~r/1st argument value "bar" does not match 1st parameter's type atom\(\) \("arg1"\)/ ) end test "named and unnamed arg pass" do - TestMock |> expect(:foo_named_and_unnamed_arg, fn _arg1, _arg2 -> :ok end) - assert :ok == TestMock.foo_named_and_unnamed_arg(:bar, 1) + assert_pass(:foo_named_and_unnamed_arg, [:bar, 1], :ok) end test "named and unnamed arg fail" do - TestMock |> expect(:foo_named_and_unnamed_arg, fn _arg1, _arg2 -> :ok end) - - assert_raise( - Hammox.TypeMatchError, - ~r/2nd argument value "baz" does not match 2nd parameter's type number\(\) \("arg2"\)/, - fn -> TestMock.foo_named_and_unnamed_arg(:bar, "baz") end + assert_fail( + :foo_named_and_unnamed_arg, + [:bar, "baz"], + :ok, + ~r/2nd argument value "baz" does not match 2nd parameter's type number\(\) \("arg2"\)/ ) end test "remote type arg pass" do - TestMock |> expect(:foo_remote_type_arg, fn _ -> :ok end) - assert :ok == TestMock.foo_remote_type_arg([]) + assert_pass(:foo_remote_type_arg, [[]], :ok) end end describe "multiple typespec for one function" do test "passes first typespec" do - TestMock |> expect(:foo_multiple_typespec, fn _ -> :a end) - assert :a == TestMock.foo_multiple_typespec(:a) + assert_pass(:foo_multiple_typespec, [:a], :a) end test "passes second typespec" do - TestMock |> expect(:foo_multiple_typespec, fn _ -> :b end) - assert :b == TestMock.foo_multiple_typespec(:b) + assert_pass(:foo_multiple_typespec, [:b], :b) end test "fails mix of typespecs" do - TestMock |> expect(:foo_multiple_typespec, fn _ -> :b end) - assert_raise Hammox.TypeMatchError, fn -> TestMock.foo_multiple_typespec(:a) end + assert_fail(:foo_multiple_typespec, [:a], :b) end end @@ -1294,46 +1230,78 @@ defmodule HammoxTest do describe "guarded functions" do test "pass" do - TestMock |> expect(:foo_guarded, fn arg -> [arg] end) - assert [1] == TestMock.foo_guarded(1) + assert_pass(:foo_guarded, [1], [1]) end test "fail" do - TestMock |> expect(:foo_guarded, fn _ -> 1 end) - assert_raise(Hammox.TypeMatchError, fn -> TestMock.foo_guarded(1) end) + assert_fail(:foo_guarded, [1], 1) end end - describe "expect/4" do - test "protects mocks" do - TestMock |> expect(:foo_none, fn -> :baz end) - assert_raise(Hammox.TypeMatchError, fn -> TestMock.foo_none() end) + describe "module with multiple behaviours" do + test "pass" do + assert :ok = + TypeChecker.validate(Impl, :other_foo, [456], 123, [ + AdditionalBehaviour, + SmallBehaviour + ]) + + assert :ok = + TypeChecker.validate(Impl, :additional_foo, [], 123, [ + AdditionalBehaviour, + SmallBehaviour + ]) + + assert :ok = + TypeChecker.validate(Impl, :nospec_fun, [], 1, [ + AdditionalBehaviour, + SmallBehaviour + ]) end - end - describe "stub/3" do - test "protects stubs" do - TestMock |> stub(:foo_none, fn -> :baz end) - assert_raise(Hammox.TypeMatchError, fn -> TestMock.foo_none() end) + test "fail" do + assert {:error, error} = + TypeChecker.validate(Impl, :other_foo, ["binary"], :not_a_number, [ + AdditionalBehaviour, + SmallBehaviour + ]) + + message = Ham.TypeMatchError.message(error) + assert Regex.match?(~r/"binary" does not match 1st parameter's type number/, message) + assert Regex.match?(~r/:not_a_number does not match type number/, message) end end - defp assert_pass(function_name, value) do - TestMock |> expect(function_name, fn -> value end) - result = apply(TestMock, function_name, []) - assert value == result + test "nospec" do + assert_pass(:nospec_fun, [], :ok) + end + + defp assert_pass(function_name, args \\ [], return_value) do + assert :ok = TypeChecker.validate(TestModule, function_name, args, return_value) + + assert :ok = TypeChecker.validate(Impl, function_name, args, return_value, [Behaviour]) + end + + defp assert_fail(function_name, return_value), do: assert_fail(function_name, [], return_value) + + defp assert_fail(function_name, args, return_value) when is_list(args) do + assert {:error, _error} = + TypeChecker.validate(TestModule, function_name, args, return_value) + + assert {:error, _error} = + TypeChecker.validate(TestModule, function_name, args, return_value, [Behaviour]) end - defp assert_fail(function_name, value) do - TestMock |> expect(function_name, fn -> value end) - assert_raise(Hammox.TypeMatchError, fn -> apply(TestMock, function_name, []) end) + defp assert_fail(function_name, return_value, expected_message) + when is_struct(expected_message, Regex) do + assert_fail(function_name, [], return_value, expected_message) end - defp assert_fail(function_name, value, expected_message) do - TestMock |> expect(function_name, fn -> value end) + defp assert_fail(function_name, args, return_value, expected_message) do + assert {:error, error} = + TypeChecker.validate(TestModule, function_name, args, return_value) - assert_raise(Hammox.TypeMatchError, expected_message, fn -> - apply(TestMock, function_name, []) - end) + message = Ham.TypeMatchError.message(error) + assert Regex.match?(expected_message, message) end end diff --git a/test/ham_test.exs b/test/ham_test.exs new file mode 100644 index 0000000..6f03136 --- /dev/null +++ b/test/ham_test.exs @@ -0,0 +1,41 @@ +defmodule HamTest do + use ExUnit.Case, async: true + alias Ham.Test.CustomAccess + doctest Ham + require Ham + + describe "apply/2" do + test "with behaviours" do + message = """ + Returned value :wrong does not match type {:ok, Access.value()} | :error. + Value :wrong does not match type {:ok, Access.value()} | :error.\ + """ + + assert_raise(Ham.TypeMatchError, message, fn -> + Ham.apply(CustomAccess.fetch([], "key"), behaviours: [Access]) + end) + + assert Ham.apply(CustomAccess.pop([], "key"), behaviours: [Access]) == :wrong + end + + test "invalid call" do + message = """ + 1st argument value "a" does not match 1st parameter's type byte(). + Value "a" does not match type 0..255.\ + """ + + assert_raise(Ham.TypeMatchError, message, fn -> + Ham.apply(URI.char_reserved?("a")) + end) + + assert_raise(Ham.TypeMatchError, message, fn -> + Ham.apply(URI, :char_reserved?, ["a"]) + end) + end + end + + describe "validate/5" do + test "valid args and return value" do + end + end +end diff --git a/test/hammox/protect_test.exs b/test/hammox/protect_test.exs deleted file mode 100644 index d8a7c75..0000000 --- a/test/hammox/protect_test.exs +++ /dev/null @@ -1,101 +0,0 @@ -defmodule Hammox.ProtectTest do - alias Hammox.Test.Protect, as: ProtectTest - - use ExUnit.Case, async: true - - use Hammox.Protect, module: ProtectTest.BehaviourImplementation - - use Hammox.Protect, - module: ProtectTest.Implementation, - behaviour: ProtectTest.Behaviour, - funs: [behaviour_wrong_typespec: 0] - - test "using Protect without module throws an exception" do - module_string = """ - defmodule ProtectWithoutModule do - use Hammox.Protect - end - """ - - assert_raise ArgumentError, ~r/Please specify :module to protect/, fn -> - Code.compile_string(module_string) - end - end - - test "using Protect on a module without callbacks throws an exception" do - module_string = """ - defmodule ProtectNoCallbacks do - use Hammox.Protect, module: Hammox.Test.Protect.Implementation - end - """ - - assert_raise ArgumentError, - ~r/The module Hammox.Test.Protect.Implementation does not contain any callbacks./, - fn -> - Code.compile_string(module_string) - end - end - - test "using Protect on a behaviour without callbacks throws an exception" do - module_string = """ - defmodule ProtectNoCallbacks do - use Hammox.Protect, module: Hammox.Test.Protect.Implementation, behaviour: Hammox.Test.Protect.EmptyBehaviour - end - """ - - assert_raise ArgumentError, - ~r/The module Hammox.Test.Protect.EmptyBehaviour does not contain any callbacks./, - fn -> - Code.compile_string(module_string) - end - end - - test "using Protect creates protected versions of functions from given behaviour-implementation module" do - assert_raise Hammox.TypeMatchError, fn -> behaviour_implementation_wrong_typespec() end - end - - test "using Protect creates protected versions of functions from given behaviour and implementation" do - assert_raise Hammox.TypeMatchError, fn -> behaviour_wrong_typespec() end - end - - defmodule MultiProtect do - use Hammox.Protect, - module: Hammox.Test.MultiBehaviourImplementation, - behaviour: Hammox.Test.SmallBehaviour, - behaviour: Hammox.Test.AdditionalBehaviour - end - - test "using Protect with multiple behaviour opts creates expected functions" do - # Hammox.Test.SmallBehaviour - assert_raise Hammox.TypeMatchError, fn -> MultiProtect.foo() end - assert 1 == MultiProtect.other_foo() - assert 1 == MultiProtect.other_foo(10) - - # Hammox.Test.AdditionalBehaviour - assert 1 == MultiProtect.additional_foo() - end - - defmodule MultiProtectWithFuns do - use Hammox.Protect, - module: Hammox.Test.MultiBehaviourImplementation, - behaviour: Hammox.Test.SmallBehaviour, - funs: [other_foo: 1], - behaviour: Hammox.Test.AdditionalBehaviour - end - - test "using Protect with multiple behaviour / funs opts creates expected functions" do - # Hammox.Test.SmallBehaviour - assert_raise UndefinedFunctionError, - ~r[MultiProtectWithFuns.foo/0 is undefined or private], - &MultiProtectWithFuns.foo/0 - - assert_raise UndefinedFunctionError, - ~r[MultiProtectWithFuns.other_foo/0 is undefined or private], - &MultiProtectWithFuns.other_foo/0 - - assert 1 == MultiProtectWithFuns.other_foo(10) - - # Hammox.Test.AdditionalBehaviour - assert 1 == MultiProtectWithFuns.additional_foo() - end -end diff --git a/test/support/additional_behaviour.ex b/test/support/additional_behaviour.ex index b7b480b..be72f13 100644 --- a/test/support/additional_behaviour.ex +++ b/test/support/additional_behaviour.ex @@ -1,4 +1,4 @@ -defmodule Hammox.Test.AdditionalBehaviour do +defmodule Ham.Test.AdditionalBehaviour do @moduledoc false @callback additional_foo() :: number() diff --git a/test/support/behaviour.ex b/test/support/behaviour.ex index 5b32fba..ed4cbe3 100644 --- a/test/support/behaviour.ex +++ b/test/support/behaviour.ex @@ -1,4 +1,4 @@ -defmodule Hammox.Test.Behaviour do +defmodule Ham.Test.Behaviour do @moduledoc false @callback foo_any() :: any() @@ -26,7 +26,7 @@ defmodule Hammox.Test.Behaviour do @callback foo_bitstring_size_literal() :: <<_::3>> @callback foo_bitstring_unit_literal() :: <<_::_*3>> @callback foo_bitstring_size_unit_literal() :: <<_::2, _::_*3>> - @callback foo_nullary_function_literal() :: (() -> :ok) + @callback foo_nullary_function_literal() :: (-> :ok) @callback foo_binary_function_literal() :: (:a, :b -> :ok) @callback foo_any_arity_function_literal() :: (... -> :ok) @callback foo_integer_literal() :: 1 @@ -53,8 +53,8 @@ defmodule Hammox.Test.Behaviour do :__struct__ => atom(), key: number() } - @callback foo_struct_literal() :: %Hammox.Test.Struct{} - @callback foo_struct_fields_literal() :: %Hammox.Test.Struct{foo: number()} + @callback foo_struct_literal() :: %Ham.Test.Struct{} + @callback foo_struct_fields_literal() :: %Ham.Test.Struct{foo: number()} @callback foo_empty_tuple_literal() :: {} @callback foo_two_tuple_literal() :: {:ok, atom()} @@ -87,21 +87,21 @@ defmodule Hammox.Test.Behaviour do @callback foo_node :: atom() @callback foo_timeout :: timeout() - @callback foo_remote_type :: Hammox.Test.Struct.my_list() - @callback foo_remote_type_with_arg :: Hammox.Test.Struct.my_list(number()) - @callback foo_nonexistent_remote_module :: Hammox.Test.NonexistentStruct.my_list() - @callback foo_nonexistent_remote_type :: Hammox.Test.Struct.nonexistent_type() + @callback foo_remote_type :: Ham.Test.Struct.my_list() + @callback foo_remote_type_with_arg :: Ham.Test.Struct.my_list(number()) + @callback foo_nonexistent_remote_module :: Ham.Test.NonexistentStruct.my_list() + @callback foo_nonexistent_remote_type :: Ham.Test.Struct.nonexistent_type() @callback foo_protocol_remote_type :: Enumerable.t() @callback foo_no_arg() :: :ok @callback foo_unnamed_arg(atom()) :: :ok @callback foo_named_arg(arg1 :: atom()) :: :ok @callback foo_named_and_unnamed_arg(atom(), arg2 :: number()) :: :ok - @callback foo_remote_type_arg(Hammox.Test.Struct.my_list()) :: :ok + @callback foo_remote_type_arg(Ham.Test.Struct.my_list()) :: :ok - @callback foo_user_type() :: Hammox.Test.Struct.my_type_user() + @callback foo_user_type() :: Ham.Test.Struct.my_type_user() @type type_from_behaviour :: :foo_type - @callback foo_behaviour_user_type :: type_from_behaviour() + @callback foo_module_user_type :: type_from_behaviour() @callback foo_ann_type_user_type(arg :: type_from_behaviour) :: :ok @callback foo_annotated_return_type() :: return_value :: :return_type @@ -113,7 +113,7 @@ defmodule Hammox.Test.Behaviour do @callback foo_multiple_typespec(arg :: :a) :: :a @callback foo_multiple_typespec(arg :: :b) :: :b - @callback foo_remote_param_type() :: Hammox.Test.Struct.ok(local()) + @callback foo_remote_param_type() :: Ham.Test.Struct.ok(local()) @type local :: :local diff --git a/test/support/behaviour_implementation.ex b/test/support/behaviour_implementation.ex deleted file mode 100644 index 8daa309..0000000 --- a/test/support/behaviour_implementation.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule Hammox.Test.BehaviourImplementation do - @moduledoc false - - @callback foo() :: number() - def foo do - :bar - end - - @callback other_foo() :: number() - def other_foo do - 1 - end - - @callback other_foo(number()) :: number() - def other_foo(_) do - 1 - end -end diff --git a/test/support/custom_access.ex b/test/support/custom_access.ex new file mode 100644 index 0000000..5e4c9c4 --- /dev/null +++ b/test/support/custom_access.ex @@ -0,0 +1,12 @@ +defmodule Ham.Test.CustomAccess do + @behaviour Access + + @impl Access + def fetch(_data, _key), do: :wrong + @impl Access + def get_and_update(_data, _key, _function), do: :wrong + + @impl Access + @spec pop(any, any) :: :wrong + def pop(_data, _key), do: :wrong +end diff --git a/test/support/impl.ex b/test/support/impl.ex new file mode 100644 index 0000000..1b421e2 --- /dev/null +++ b/test/support/impl.ex @@ -0,0 +1,121 @@ +defmodule Ham.Test.Impl do + @moduledoc false + @behaviour Ham.Test.Behaviour + + def foo_any, do: :ok + def foo_none, do: raise("none") + def foo_atom, do: :ok + def foo_map, do: %{} + def foo_pid, do: self() + def foo_port, do: :gen_tcp.listen(0, []) |> elem(1) + def foo_reference, do: make_ref() + def foo_struct, do: %URI{} + def foo_tuple, do: {1, 2, 3} + def foo_float, do: 1.0 + def foo_integer, do: 1 + def foo_neg_integer, do: -1 + def foo_non_neg_integer, do: 0 + def foo_pos_integer, do: 3 + def foo_list, do: [:a] + def foo_nonempty_list, do: [:a] + def foo_maybe_improper_list, do: [:a | :b] + def foo_nonempty_improper_list, do: [:a | :b] + def foo_nonempty_maybe_improper_list, do: [:a | :b] + def foo_atom_literal, do: :ok + def foo_empty_bitstring_literal, do: <<>> + def foo_bitstring_size_literal, do: <<1::size(3)>> + def foo_bitstring_unit_literal, do: <<1::size(9)>> + def foo_bitstring_size_unit_literal, do: <<1::8>> + def foo_nullary_function_literal, do: fn -> nil end + def foo_binary_function_literal, do: fn _, _ -> nil end + def foo_any_arity_function_literal, do: fn -> :ok end + def foo_integer_literal, do: 1 + def foo_neg_integer_literal, do: -1 + def foo_integer_range_literal, do: 2 + def foo_list_literal, do: [:ok] + def foo_empty_list_literal, do: [] + def foo_nonempty_any_list_literal, do: [1] + def foo_nonempty_list_literal, do: [:ok] + def foo_keyword_list_literal, do: [key1: :ok, key2: :ok] + def foo_empty_map_literal, do: %{} + def foo_map_required_atom_key_literal, do: %{key: :ok} + def foo_map_required_key_literal, do: %{ok: :ok} + def foo_map_optional_key_literal, do: %{ok: :ok} + def foo_map_required_and_optional_key_literal, do: %{:ok => :ok, 2 => 3} + def foo_map_overlapping_required_types_literal, do: %{:ok => :ok, :error => :error, 2 => :ok} + + def foo_map_struct_key, do: %{__struct__: :foo, key: 123} + def foo_struct_literal, do: %Ham.Test.Struct{} + def foo_struct_fields_literal, do: %Ham.Test.Struct{foo: 123} + def foo_empty_tuple_literal, do: {} + def foo_two_tuple_literal, do: {:ok, :done} + def foo_term, do: :ok + def foo_arity, do: 1 + def foo_as_boolean, do: :ok + def foo_binary, do: "123" + def foo_bitstring, do: <<>> + def foo_bool, do: true + def foo_boolean, do: false + def foo_byte, do: ?A + def foo_char, do: 0x100000 + def foo_charlist, do: [65] + def foo_nonempty_charlist, do: [65] + def foo_fun, do: fn -> :ok end + def foo_function, do: fn -> :ok end + def foo_identifier, do: self() + def foo_iodata, do: "iodata" + def foo_iolist, do: ["iodata"] + def foo_keyword, do: :ok + def foo_keyword_type, do: [key: 1] + def foo_list_any, do: [] + def foo_nonempty_list_any, do: [1] + def foo_maybe_improper_list_any, do: [:a] + def foo_nonempty_maybe_improper_list_any, do: [:a] + def foo_mfa, do: {__MODULE__, :foo_mfa, 0} + def foo_module, do: __MODULE__ + def foo_no_return, do: raise("no_return") + def foo_number, do: 1 + def foo_node, do: :ok + def foo_timeout, do: 123 + def foo_remote_type, do: [] + def foo_remote_type_with_arg, do: [[1]] + def foo_nonexistent_remote_module, do: :ok + def foo_nonexistent_remote_type, do: :ok + def foo_protocol_remote_type, do: [] + def foo_no_arg, do: :ok + def foo_unnamed_arg(_atom), do: :ok + def foo_named_arg(_arg1), do: :ok + def foo_named_and_unnamed_arg(_arg1, _arg2), do: :ok + def foo_remote_type_arg(_arg), do: :ok + + def foo_user_type, do: [[:foo_type]] + @type type_from_module :: :foo_type + def foo_module_user_type, do: :foo_type + def foo_ann_type_user_type(_), do: :ok + def foo_annotated_return_type, do: :return_type + def foo_annotated_type_in_container, do: {:correct_type} + def foo_union, do: :b + def foo_uneven_union, do: %{a: 1} + def foo_multiple_typespec(_), do: :a + def foo_remote_param_type, do: {:ok, :local} + + @type local :: :local + + @type param_type_1(arg1) :: arg1 + @type param_type_2(arg2) :: param_type_1(arg2) + def foo_nested_param_types, do: :param + + @type multiline_param_type(param) :: %{ + value: param + } + def foo_multiline_param_type, do: %{value: :arg} + + @typep private_type :: :private_value + @type type_including_private_type :: private_type + def foo_private_type, do: :private_value + + @opaque opaque_type :: :opaque_value + def foo_opaque_type, do: :opaque_value + + def foo_guarded(_arg), do: [1] +end diff --git a/test/support/module.ex b/test/support/module.ex new file mode 100644 index 0000000..d2ba5f8 --- /dev/null +++ b/test/support/module.ex @@ -0,0 +1,243 @@ +defmodule Ham.Test.TestModule do + @moduledoc false + + @spec sum(number, number) :: number + def sum(a, b), do: a + b + + @spec foo_any :: any + def foo_any, do: :ok + @spec foo_none :: none + def foo_none, do: raise("none") + @spec foo_atom :: atom + def foo_atom, do: :ok + @spec foo_map :: map + def foo_map, do: %{} + @spec foo_pid :: pid + def foo_pid, do: self() + @spec foo_port :: port + def foo_port, do: :gen_tcp.listen(0, []) |> elem(1) + @spec foo_reference :: reference + def foo_reference, do: make_ref() + @spec foo_struct :: struct + def foo_struct, do: %URI{} + @spec foo_tuple :: tuple + def foo_tuple, do: {1, 2, 3} + @spec foo_float :: float + def foo_float, do: 1.0 + @spec foo_integer :: integer + def foo_integer, do: 1 + @spec foo_neg_integer :: neg_integer + def foo_neg_integer, do: -1 + @spec foo_non_neg_integer :: non_neg_integer + def foo_non_neg_integer, do: 0 + @spec foo_pos_integer :: pos_integer + def foo_pos_integer, do: 3 + @spec foo_list :: list(atom) + def foo_list, do: [:a] + @spec foo_nonempty_list :: nonempty_list(atom) + def foo_nonempty_list, do: [:a] + @spec foo_maybe_improper_list :: maybe_improper_list(:a, :b) + def foo_maybe_improper_list, do: [:a | :b] + @spec foo_nonempty_improper_list :: nonempty_improper_list(:a, :b) + def foo_nonempty_improper_list, do: [:a | :b] + @spec foo_nonempty_maybe_improper_list :: nonempty_maybe_improper_list(:a, :b) + def foo_nonempty_maybe_improper_list, do: [:a | :b] + + @spec foo_atom_literal :: :ok + def foo_atom_literal, do: :ok + @spec foo_empty_bitstring_literal :: <<>> + def foo_empty_bitstring_literal, do: <<>> + @spec foo_bitstring_size_literal :: <<_::3>> + def foo_bitstring_size_literal, do: <<1::size(3)>> + @spec foo_bitstring_unit_literal :: <<_::_*3>> + def foo_bitstring_unit_literal, do: <<1::size(9)>> + @spec foo_bitstring_size_unit_literal :: <<_::2, _::_*3>> + def foo_bitstring_size_unit_literal, do: <<1::8>> + @spec foo_nullary_function_literal :: (-> :ok) + def foo_nullary_function_literal, do: fn -> nil end + @spec foo_binary_function_literal :: (:a, :b -> :ok) + def foo_binary_function_literal, do: fn _, _ -> nil end + @spec foo_any_arity_function_literal :: (... -> :ok) + def foo_any_arity_function_literal, do: fn -> :ok end + @spec foo_integer_literal :: 1 + def foo_integer_literal, do: 1 + @spec foo_neg_integer_literal :: -1 + def foo_neg_integer_literal, do: -1 + @spec foo_integer_range_literal :: 1..10 + def foo_integer_range_literal, do: 2 + @spec foo_list_literal :: [atom] + def foo_list_literal, do: [:ok] + @spec foo_empty_list_literal :: [] + def foo_empty_list_literal, do: [] + @spec foo_nonempty_any_list_literal :: [...] + def foo_nonempty_any_list_literal, do: [1] + @spec foo_nonempty_list_literal :: [atom, ...] + def foo_nonempty_list_literal, do: [:ok] + @spec foo_keyword_list_literal :: [key1: atom, key2: number] + def foo_keyword_list_literal, do: [key1: :ok, key2: :ok] + @spec foo_empty_map_literal :: %{} + def foo_empty_map_literal, do: %{} + @spec foo_map_required_atom_key_literal :: %{key: atom} + def foo_map_required_atom_key_literal, do: %{key: :ok} + @spec foo_map_required_key_literal :: %{required(atom) => atom} + def foo_map_required_key_literal, do: %{ok: :ok} + @spec foo_map_optional_key_literal :: %{optional(atom) => atom} + def foo_map_optional_key_literal, do: %{ok: :ok} + + @spec foo_map_required_and_optional_key_literal :: %{ + required(atom) => atom, + optional(number) => number + } + def foo_map_required_and_optional_key_literal, do: %{:ok => :ok, 2 => 3} + + @spec foo_map_overlapping_required_types_literal :: %{ + required(atom) => atom, + required(atom | number) => atom + } + def foo_map_overlapping_required_types_literal, do: %{:ok => :ok, :error => :error, 2 => :ok} + + @spec foo_map_struct_key :: %{ + :__struct__ => atom, + key: number + } + def foo_map_struct_key, do: %{__struct__: :foo, key: 123} + @spec foo_struct_literal :: %Ham.Test.Struct{} + def foo_struct_literal, do: %Ham.Test.Struct{} + @spec foo_struct_fields_literal :: %Ham.Test.Struct{foo: number} + def foo_struct_fields_literal, do: %Ham.Test.Struct{foo: 123} + @spec foo_empty_tuple_literal :: {} + def foo_empty_tuple_literal, do: {} + @spec foo_two_tuple_literal :: {:ok, atom} + def foo_two_tuple_literal, do: {:ok, :done} + + @spec foo_term :: term + def foo_term, do: :ok + @spec foo_arity :: arity + def foo_arity, do: 1 + @spec foo_as_boolean :: as_boolean(:ok | nil) + def foo_as_boolean, do: :ok + @spec foo_binary :: binary + def foo_binary, do: "123" + @spec foo_bitstring :: bitstring + def foo_bitstring, do: <<>> + @spec foo_bool :: bool + def foo_bool, do: true + @spec foo_boolean :: boolean + def foo_boolean, do: false + @spec foo_byte :: byte + def foo_byte, do: ?A + @spec foo_char :: char + def foo_char, do: 0x100000 + @spec foo_charlist :: charlist + def foo_charlist, do: [65] + @spec foo_nonempty_charlist :: nonempty_charlist + def foo_nonempty_charlist, do: [65] + @spec foo_fun :: fun + def foo_fun, do: fn -> :ok end + @spec foo_function :: function + def foo_function, do: fn -> :ok end + @spec foo_identifier :: identifier + def foo_identifier, do: self() + @spec foo_iodata :: iodata + def foo_iodata, do: "iodata" + @spec foo_iolist :: iolist + def foo_iolist, do: ["iodata"] + @spec foo_keyword :: keyword + def foo_keyword, do: [a: "b"] + @spec foo_keyword_type :: keyword(number) + def foo_keyword_type, do: [key: 1] + @spec foo_list_any :: list + def foo_list_any, do: [] + @spec foo_nonempty_list_any :: nonempty_list + def foo_nonempty_list_any, do: [1] + @spec foo_maybe_improper_list_any :: maybe_improper_list + def foo_maybe_improper_list_any, do: [:a] + @spec foo_nonempty_maybe_improper_list_any :: nonempty_maybe_improper_list + def foo_nonempty_maybe_improper_list_any, do: [:a] + @spec foo_mfa :: mfa + def foo_mfa, do: {__MODULE__, :foo_mfa, 0} + @spec foo_module :: module + def foo_module, do: __MODULE__ + @spec foo_no_return :: no_return + def foo_no_return, do: raise("no_return") + @spec foo_number :: number + def foo_number, do: 1 + @spec foo_node :: atom + def foo_node, do: :ok + @spec foo_timeout :: timeout + def foo_timeout, do: 123 + + @spec foo_remote_type :: Ham.Test.Struct.my_list() + def foo_remote_type, do: [] + @spec foo_remote_type_with_arg :: Ham.Test.Struct.my_list(number) + def foo_remote_type_with_arg, do: [[1]] + @spec foo_nonexistent_remote_module :: Ham.Test.NonexistentStruct.my_list() + def foo_nonexistent_remote_module, do: :ok + @spec foo_nonexistent_remote_type :: Ham.Test.Struct.nonexistent_type() + def foo_nonexistent_remote_type, do: :ok + @spec foo_protocol_remote_type :: Enumerable.t() + def foo_protocol_remote_type, do: [] + + @spec foo_no_arg :: :ok + def foo_no_arg, do: :ok + @spec foo_unnamed_arg(atom) :: :ok + def foo_unnamed_arg(_atom), do: :ok + @spec foo_named_arg(arg1 :: atom) :: :ok + def foo_named_arg(_arg1), do: :ok + @spec foo_named_and_unnamed_arg(atom, arg2 :: number) :: :ok + def foo_named_and_unnamed_arg(_arg1, _arg2), do: :ok + @spec foo_remote_type_arg(Ham.Test.Struct.my_list()) :: :ok + def foo_remote_type_arg(_arg), do: :ok + + @spec foo_user_type :: Ham.Test.Struct.my_type_user() + def foo_user_type, do: [[:foo_type]] + @type type_from_module :: :foo_type + @spec foo_module_user_type :: type_from_module + def foo_module_user_type, do: :foo_type + @spec foo_ann_type_user_type(arg :: type_from_module) :: :ok + def foo_ann_type_user_type(_), do: :ok + + @spec foo_annotated_return_type :: return_value :: :return_type + def foo_annotated_return_type, do: :return_type + @spec foo_annotated_type_in_container :: {correct_type_name :: :correct_type} + def foo_annotated_type_in_container, do: {:correct_type} + + @spec foo_union :: :a | :b + def foo_union, do: :b + @spec foo_uneven_union :: :a | %{a: 1} + def foo_uneven_union, do: %{a: 1} + + @spec foo_multiple_typespec(arg :: :a) :: :a + @spec foo_multiple_typespec(arg :: :b) :: :b + def foo_multiple_typespec(_), do: :a + + @spec foo_remote_param_type :: Ham.Test.Struct.ok(local) + def foo_remote_param_type, do: {:ok, :local} + + @type local :: :local + + @type param_type_1(arg1) :: arg1 + @type param_type_2(arg2) :: param_type_1(arg2) + @spec foo_nested_param_types :: param_type_2(:param) + def foo_nested_param_types, do: :param + + @type multiline_param_type(param) :: %{ + value: param + } + @spec foo_multiline_param_type :: multiline_param_type(:arg) + def foo_multiline_param_type, do: %{value: :arg} + + @typep private_type :: :private_value + @type type_including_private_type :: private_type + @spec foo_private_type :: type_including_private_type + def foo_private_type, do: :private_value + + @opaque opaque_type :: :opaque_value + @spec foo_opaque_type :: opaque_type + def foo_opaque_type, do: :opaque_value + + @spec foo_guarded(arg) :: [arg] when arg: integer + def foo_guarded(_arg), do: [1] + + def nospec_fun, do: :ok +end diff --git a/test/support/multi_behaviour_implementation.ex b/test/support/multi_behaviour_implementation.ex index 4802770..9879f06 100644 --- a/test/support/multi_behaviour_implementation.ex +++ b/test/support/multi_behaviour_implementation.ex @@ -1,19 +1,19 @@ -defmodule Hammox.Test.MultiBehaviourImplementation do +defmodule Ham.Test.MultiBehaviourImplementation do @moduledoc false - @behaviour Hammox.Test.SmallBehaviour - @behaviour Hammox.Test.AdditionalBehaviour + @behaviour Ham.Test.SmallBehaviour + @behaviour Ham.Test.AdditionalBehaviour - @impl Hammox.Test.SmallBehaviour - def foo, do: :bar + @impl Ham.Test.SmallBehaviour + def foo, do: 1 - @impl Hammox.Test.SmallBehaviour + @impl Ham.Test.SmallBehaviour def other_foo, do: 1 - @impl Hammox.Test.SmallBehaviour + @impl Ham.Test.SmallBehaviour def other_foo(_), do: 1 - @impl Hammox.Test.AdditionalBehaviour + @impl Ham.Test.AdditionalBehaviour def additional_foo, do: 1 def nospec_fun, do: 1 diff --git a/test/support/other_struct.ex b/test/support/other_struct.ex index 4931634..97c1373 100644 --- a/test/support/other_struct.ex +++ b/test/support/other_struct.ex @@ -1,4 +1,4 @@ -defmodule Hammox.Test.OtherStruct do +defmodule Ham.Test.OtherStruct do @moduledoc false defstruct [:foo] end diff --git a/test/support/protect/behaviour.ex b/test/support/protect/behaviour.ex deleted file mode 100644 index 9f66380..0000000 --- a/test/support/protect/behaviour.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule Hammox.Test.Protect.Behaviour do - @moduledoc false - - @callback behaviour_wrong_typespec() :: :foo -end diff --git a/test/support/protect/behaviour_implementation.ex b/test/support/protect/behaviour_implementation.ex deleted file mode 100644 index dc23ca3..0000000 --- a/test/support/protect/behaviour_implementation.ex +++ /dev/null @@ -1,8 +0,0 @@ -defmodule Hammox.Test.Protect.BehaviourImplementation do - @moduledoc false - - @callback behaviour_implementation_wrong_typespec() :: :foo - def behaviour_implementation_wrong_typespec do - :wrong - end -end diff --git a/test/support/protect/empty_behaviour.ex b/test/support/protect/empty_behaviour.ex deleted file mode 100644 index c5f8d52..0000000 --- a/test/support/protect/empty_behaviour.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule Hammox.Test.Protect.EmptyBehaviour do - @moduledoc false -end diff --git a/test/support/protect/implementation.ex b/test/support/protect/implementation.ex deleted file mode 100644 index f892910..0000000 --- a/test/support/protect/implementation.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule Hammox.Test.Protect.Implementation do - @moduledoc false - - def behaviour_wrong_typespec, do: :wrong -end diff --git a/test/support/small_behaviour.ex b/test/support/small_behaviour.ex index 6da0aed..c8f2cb8 100644 --- a/test/support/small_behaviour.ex +++ b/test/support/small_behaviour.ex @@ -1,4 +1,4 @@ -defmodule Hammox.Test.SmallBehaviour do +defmodule Ham.Test.SmallBehaviour do @moduledoc false @callback foo() :: number() diff --git a/test/support/small_implementation.ex b/test/support/small_implementation.ex index 01c1f1e..dbc7565 100644 --- a/test/support/small_implementation.ex +++ b/test/support/small_implementation.ex @@ -1,20 +1,12 @@ -defmodule Hammox.Test.SmallImplementation do +defmodule Ham.Test.SmallImplementation do @moduledoc false - @behaviour Hammox.Test.SmallBehaviour - def foo do - :bar - end + @behaviour Ham.Test.SmallBehaviour + def foo, do: 1 - def other_foo do - 1 - end + def other_foo, do: 1 - def other_foo(_) do - 1 - end + def other_foo(_), do: 1 - def nospec_fun do - 1 - end + def nospec_fun, do: 1 end diff --git a/test/support/struct.ex b/test/support/struct.ex index 999c6c2..2ea8a53 100644 --- a/test/support/struct.ex +++ b/test/support/struct.ex @@ -1,4 +1,4 @@ -defmodule Hammox.Test.Struct do +defmodule Ham.Test.Struct do @moduledoc false defstruct [:foo] diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..ffb3abc 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,4 @@ ExUnit.start() + +[Ham.Test.TestModule, Ham.Test.Impl] +|> Enum.each(&Code.ensure_loaded(&1))