├── test ├── test_helper.exs └── forgery_test.exs ├── .formatter.exs ├── CHANGELOG.md ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── mix.exs ├── mix.lock ├── README.md └── lib └── forgery.ex /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.2.0 4 | 5 | __Breaking changes:__ 6 | 7 | * Changed `Forgery.put_new_field/3` to be a plain function that accepts anonymous functions. 8 | * Infroduced `Forgery.lazy/1` to make `Forgery.put_new_field/3` usage more streamlined. 9 | 10 | ## v0.1.0 11 | 12 | Initial release. 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | fortory-*.tar 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Cẩm Huỳnh and Aleksei Magusev 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | uses: lexmag/elixir-actions/.github/workflows/lint.yml@v1 13 | 14 | test: 15 | name: Test suite 16 | runs-on: ubuntu-20.04 17 | 18 | strategy: 19 | matrix: 20 | include: 21 | - otp: "24" 22 | elixir: "1.14" 23 | - otp: "20" 24 | elixir: "1.7" 25 | 26 | env: 27 | MIX_ENV: test 28 | 29 | steps: 30 | - uses: actions/checkout@v3 31 | 32 | - name: Install OTP and Elixir 33 | uses: erlef/setup-beam@v1 34 | with: 35 | otp-version: ${{ matrix.otp }} 36 | elixir-version: ${{ matrix.elixir }} 37 | 38 | - name: Install dependencies 39 | run: mix deps.get --only test 40 | 41 | - name: Run tests 42 | run: mix test 43 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Forgery.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.2.0" 5 | @source_url "https://github.com/craftjectory/forgery" 6 | 7 | def project() do 8 | [ 9 | app: :forgery, 10 | version: @version, 11 | elixir: "~> 1.6", 12 | deps: deps(), 13 | 14 | # Hex. 15 | package: package(), 16 | description: description(), 17 | 18 | # Docs. 19 | name: "Forgery", 20 | docs: docs() 21 | ] 22 | end 23 | 24 | def application(), do: [] 25 | 26 | defp deps() do 27 | [ 28 | {:ex_doc, "~> 0.21", only: :dev, runtime: false} 29 | ] 30 | end 31 | 32 | defp description() do 33 | "A slim test data generator that does not compromise extensibility." 34 | end 35 | 36 | defp package() do 37 | [ 38 | maintainers: ["Aleksei Magusev", "Cẩm Huỳnh"], 39 | licenses: ["ISC"], 40 | links: %{"GitHub" => @source_url} 41 | ] 42 | end 43 | 44 | defp docs() do 45 | [ 46 | main: "Forgery", 47 | source_ref: "v#{@version}", 48 | source_url: @source_url, 49 | extras: [ 50 | "README.md", 51 | "CHANGELOG.md" 52 | ] 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 3 | "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, 4 | "makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49736fe5b66a08d8575bf5321d716bac5da20c8e6b97714fec2bcd6febcfa1f8"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 6 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, 7 | } 8 | -------------------------------------------------------------------------------- /test/forgery_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ForgeryTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule User do 5 | defstruct [:id, :name, :password] 6 | end 7 | 8 | defmodule MyFactory do 9 | use Forgery 10 | 11 | def make(:user, fields) do 12 | fields 13 | |> put_new_field(:id, lazy(make_unique_integer())) 14 | |> put_new_field(:name, &("user#" <> Integer.to_string(&1.id))) 15 | |> put_new_field(:name, fn _ -> raise("unexpected") end) 16 | |> create_struct(User) 17 | end 18 | end 19 | 20 | doctest Forgery, import: true 21 | 22 | test "make/1 and make/2" do 23 | assert %User{ 24 | id: id, 25 | name: name 26 | } = MyFactory.make(:user) 27 | 28 | assert is_integer(id) 29 | assert id > 0 30 | assert name == "user##{id}" 31 | 32 | assert %User{ 33 | id: 100, 34 | name: "user#100" 35 | } = MyFactory.make(:user, id: 100) 36 | 37 | assert %User{name: "John"} = MyFactory.make(:user, name: "John") 38 | end 39 | 40 | test "make_many/4" do 41 | assert [user1, user2] = MyFactory.make_many(:user, 2, %{password: "123456"}) 42 | assert user1.id != user2.id 43 | assert user1.name != user2.name 44 | 45 | assert user1.password == "123456" 46 | assert user2.password == "123456" 47 | end 48 | 49 | test "make_many/2 with zero amount" do 50 | assert [] == MyFactory.make_many(:user, 0, %{password: "123456"}) 51 | end 52 | 53 | test "make_many/2 with negative amount should not be called" do 54 | assert_raise FunctionClauseError, 55 | "no function clause matching in ForgeryTest.MyFactory.make_many/3", 56 | fn -> 57 | MyFactory.make_many(:user, -1, %{password: "123456"}) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Forgery 2 | 3 | ![CI Status](https://github.com/kraftjectory/forgery/workflows/CI/badge.svg) 4 | [![Hex Version](https://img.shields.io/hexpm/v/forgery.svg)](https://hex.pm/packages/forgery) 5 | 6 | Forgery is a slim yet extensible data generator in Elixir. 7 | 8 | ## Installation 9 | 10 | ```elixir 11 | def deps() do 12 | [{:forgery, "~> 0.1"}] 13 | end 14 | ``` 15 | 16 | ## Overview 17 | 18 | Full documentation can be found at [https://hexdocs.pm/forgery](https://hexdocs.pm/forgery). 19 | 20 | Forgery provides a few simple APIs to work with: 21 | 22 | ```elixir 23 | defmodule User do 24 | defstruct [:id, :name, :password] 25 | end 26 | 27 | defmodule MyFactory do 28 | use Forgery 29 | 30 | def make(:user, fields) do 31 | fields 32 | |> put_new_field(:id, lazy(make_unique_integer())) 33 | |> put_new_field(:name, &("user#" <> Integer.to_string(&1.id))) 34 | |> create_struct(User) 35 | end 36 | end 37 | 38 | iex> import MyFactory 39 | iex> 40 | iex> %User{} = make(:user) 41 | iex> %User{id: 42, name: "user#42"} = make(:user, id: 42) 42 | iex> [%User{}, %User{}] = make_many(:user, 2) 43 | ``` 44 | 45 | And just as simple as that! 46 | 47 | ## Ecto integration 48 | 49 | Forgery was built with easy Ecto integration in mind, though not limiting to it. 50 | 51 | For example you use Ecto and have `MyRepo`. You can add a function, says `insert!` and `insert_many!`, into the factory: 52 | 53 | ```elixir 54 | defmodule MyFactory do 55 | def insert!(factory_name, fields \\ %{}) do 56 | factory_name 57 | |> make(fields) 58 | |> MyRepo.insert!() 59 | end 60 | 61 | def insert_many!(factory_name, amount, fields \\ %{}) when amount >= 1 do 62 | [%schema{} | _] = entities = make_many(factory_name, amount, fields) 63 | 64 | {^amount, persisted_entities} = MyRepo.insert_all(schema, entities, returning: true) 65 | 66 | persisted_entities 67 | end 68 | end 69 | 70 | user = insert!(:user) 71 | users = insert_many!(:user, 10, %{password: "1234567890"}) 72 | ``` 73 | 74 | ## Licensing 75 | 76 | This software is licensed under [the ISC license](LICENSE). 77 | -------------------------------------------------------------------------------- /lib/forgery.ex: -------------------------------------------------------------------------------- 1 | defmodule Forgery do 2 | @moduledoc """ 3 | Forgery is a slim yet extensible data generator in Elixir. 4 | 5 | Forgery provides a few simple APIs to work with. To get started, you 6 | need to implement the `make/2` callback: 7 | 8 | defmodule User do 9 | defstruct [:id, :name, :password] 10 | end 11 | 12 | defmodule MyFactory do 13 | use Forgery 14 | 15 | def make(:user, fields) do 16 | fields 17 | |> put_new_field(:id, lazy(make_unique_integer())) 18 | |> put_new_field(:name, &("user#" <> Integer.to_string(&1.id))) 19 | |> create_struct(User) 20 | end 21 | end 22 | 23 | iex> import MyFactory 24 | iex> 25 | iex> %User{} = make(:user) 26 | iex> [%User{}, %User{}] = make_many(:user, 2) 27 | iex> make(:user, id: 42) 28 | %User{id: 42, name: "user#42"} 29 | 30 | And just as simple as that! 31 | 32 | ## Ecto integration 33 | 34 | Forgery was built with easy Ecto integration in mind, though not limiting to it. 35 | 36 | For example if you use Ecto and have `MyRepo`. You can add a function, says `insert!`, into the factory: 37 | 38 | defmodule MyFactory do 39 | def insert!(factory_name, fields \\ %{}) do 40 | factory_name 41 | |> make(fields) 42 | |> MyRepo.insert!() 43 | end 44 | 45 | def insert_many!(factory_name, amount, fields \\ %{}) when amount >= 1 do 46 | [%schema{} | _] = entities = make_many(factory_name, amount, fields) 47 | 48 | {_, persisted_entities} = MyRepo.insert_all(schema, entities, returning: true) 49 | 50 | persisted_entities 51 | end 52 | end 53 | 54 | user = insert!(:user) 55 | users = insert_many!(:user, 10, %{password: "1234567890"}) 56 | 57 | """ 58 | 59 | @type factory_name() :: atom() 60 | 61 | @doc """ 62 | Makes data from the given factory. 63 | 64 | The implementation of this callback should take in the factory name, as well and `fields`. 65 | """ 66 | 67 | @callback make(factory_name(), fields :: Enumerable.t()) :: any() 68 | 69 | @doc """ 70 | Make multiple data from the given factory. 71 | 72 | This function is roughly equivalent to: 73 | 74 | Enum.map(1..amount, fn _ -> make(factory_name) end) 75 | 76 | with amount greater than zero. 77 | 78 | ### Example 79 | 80 | make_many(:users, 3) 81 | [ 82 | %User{id: 3, password: nil, name: "user#3"}, 83 | %User{id: 5, password: nil, name: "user#5"}, 84 | %User{id: 7, password: nil, name: "user#7"} 85 | ] 86 | 87 | """ 88 | @callback make_many(factory_name(), amount :: non_neg_integer(), fields :: Enumerable.t()) :: 89 | list(any()) 90 | 91 | defmacro __using__(_) do 92 | quote location: :keep do 93 | import Forgery 94 | 95 | @behaviour Forgery 96 | 97 | def make(factory_name, fields \\ %{}) 98 | 99 | def make_many(factory, amount, fields \\ %{}) 100 | 101 | def make_many(_factory, 0, _fields), do: [] 102 | 103 | def make_many(factory_name, amount, fields) when is_integer(amount) and amount > 0 do 104 | for _ <- 1..amount, do: make(factory_name, fields) 105 | end 106 | end 107 | end 108 | 109 | @doc """ 110 | Lazily evaluates `value_setter` and puts the result into `key` if it does not exist in `fields`. 111 | 112 | The `value_setter` function receives `fields` as an argument. 113 | 114 | iex> make_foo = fn _ -> raise("I am invoked") end 115 | iex> fields = %{foo: 1} 116 | iex> put_new_field(fields, :foo, make_foo) 117 | %{foo: 1} 118 | iex> put_new_field(fields, :bar, &(&1.foo + 100)) 119 | %{foo: 1, bar: 101} 120 | 121 | There is also helper macro `lazy/1`: 122 | 123 | iex> fields = %{foo: 2} 124 | iex> put_new_field(fields, :foo, lazy(10 * 10)) 125 | %{foo: 2} 126 | 127 | """ 128 | @spec put_new_field( 129 | fields :: Enumerable.t(), 130 | key :: any(), 131 | value_setter :: (fields :: map() -> any()) 132 | ) :: map() 133 | def put_new_field(fields, key, value_setter) when is_function(value_setter, 1) do 134 | case Map.new(fields) do 135 | %{^key => _value} = fields -> 136 | fields 137 | 138 | fields -> 139 | Map.put(fields, key, value_setter.(fields)) 140 | end 141 | end 142 | 143 | @doc """ 144 | Wraps the given `expr` into an anonymous function. 145 | 146 | It is equivalent to `fn _ -> expr end`. 147 | """ 148 | defmacro lazy(expr) do 149 | quote do 150 | fn _ -> unquote(expr) end 151 | end 152 | end 153 | 154 | @doc """ 155 | Create struct of `module` from `fields`. 156 | 157 | See `Kernel.struct!/2` for more information. 158 | 159 | iex> create_struct(%{id: 1, name: "John", password: "123456"}, User) 160 | %User{id: 1, password: "123456", name: "John"} 161 | 162 | """ 163 | 164 | @spec create_struct(fields :: Enumerable.t(), module() | struct()) :: struct() 165 | def create_struct(fields, module) do 166 | struct!(module, fields) 167 | end 168 | 169 | @doc """ 170 | Returns monotonically increasing unique integer. It would be useful when it comes to 171 | generate unique serial IDs. 172 | """ 173 | @spec make_unique_integer() :: pos_integer() 174 | def make_unique_integer() do 175 | System.unique_integer([:monotonic, :positive]) 176 | end 177 | end 178 | --------------------------------------------------------------------------------