ExUnit Patterns for Ease of Mind 12 Sep 2022
In recent months I have been working on a number of projects with my primary collaborator of recent years, Erik Hanson. Some of those projects have led to a number of open source Elixir libraries. Others may be eventually be open sourced, but are currently private.
In this post, I'd like to share a set of patterns that work together to ease test setup and organization. The examples shown will be for ExUnit and Phoenix, but could be adapted to other languages and frameworks.
defmodule Web.ProfileLiveTest do
use Test.ConnCase, async: true
@tag page: :alice, profile: [:alice]
test "shows profile info", %{pages: %{alice: page}, profiles: %{alice: alice}} do
page
|> Test.Pages.ProfilePage.visit()
|> Test.Pages.ProfilePage.assert_here()
|> Test.Pages.ProfilePage.assert_profile_info(alice.name, alice.email)
end
end
For this to fully make sense, we'll need to walk through a few concepts and examples. These examples will not be complete, and will take a while to get back to web requests, but please bear with me.
Introducing functional fixtures
In the test listed above, we have the concept of a Profile
. In this application, people's
profiles will be used in order to sign in, tag each other in comments, etc. One could imagine
the following People
context:
defmodule Core.People do
alias Core.People.Profile
@spec register_profile(Enum.t()) :: {:ok, Profile.t()} | {:error, Ecto.Changeset.t()}
def register_profile(attrs),
do: Enum.into(attrs, %{}) |> Profile.registration_changeset() |> Core.Repo.insert()
@spec get_profile_by_email(String.t()) :: Profile.t() | nil
def get_profile_by_email(email) when is_binary(email),
do: Core.Repo.get_by(Profile, email: email)
end
Tests for this context may include the following:
defmodule Core.PeopleTest do
use Test.DataCase, async: true
describe "register_profile" do
test "saves a profile" do
assert {:ok, profile} =
Core.People.register_profile(name: "Alice", email: "[email protected]")
assert profile.name == "Alice"
assert profile.email == "[email protected]"
end
end
describe "get_profile_by_email" do
test "finds a profile by email" do
{:ok, profile} = Core.People.register_profile(name: "Alice", email: "[email protected]")
assert %Core.Profile{id: profile_id} = Core.People.get_profile_by_email("[email protected]")
assert profile_id == profile.id
end
end
end
In the example test for register_profile/1
the attributes name: "Alice"
and
email: "[email protected]
are important to the test—it makes sense for them
to be specified in the call to register_profile/1
.
The test for get_profile_by_email/1
, however, doesn't care about name
or email
.
They seem redundant to the test. Further, as the application becomes more complex, one
could imagine not only more functions and features requiring a profile, but more fields
being added to profiles.
In the past I have used libraries such as ex_machina to create test data using factories. A problem with this approach is that many factory libraries (including ex_machina) circumvent the application's code by inserting data directly into the application's test database.
What if we could generate attributes for tests, but execute the application's code as well?
defmodule Core.PeopleTest do
use Test.DataCase, async: true
# ...
describe "get_profile_by_email" do
test "finds a profile by email" do
{:ok, profile} = Test.Fixtures.profile() |> Core.People.register_profile()
assert %Core.Profile{id: profile_id} = Core.People.get_profile_by_email("[email protected]")
assert profile_id == profile.id
end
end
end
Through the power of functions, we can generate maps that are passed into our real context functions.
defmodule Test.Fixtures do
@spec profile(Enum.t()) :: map()
def profile(attrs \\ %{}) do
%{
name: "Alice",
email: "[email protected]",
password: valid_profile_password(),
}
|> merge!(attrs, %Core.People.Profile{})
end
def valid_profile_password, do: "some-long-password"
# # #
# "Merge `overrides` into `defaults`, validating keys in `schema` struct, then convert into a map"
defp merge!(defaults, overrides, schema) do
defaults
|> Map.merge(Enum.into(overrides, %{}))
|> then(fn map -> struct!(schema, map) end)
|> Map.from_struct()
|> Map.delete(:__meta__)
end
end
Fields may be overridden by the caller, but any field not specified by the schema module will be dropped.
Fixture identity
In the tests listed above, we have a profile named alice
. What if we have multiple profiles?
Let's also assume that the application provides a mechanism for listing profiles.
defmodule Core.PeopleTest do
use Test.DataCase, async: true
# ...
describe "list_profiles" do
test "lists all profiles" do
{:ok, profile_1} = Test.Fixtures.profile() |> Core.People.register_profile()
{:ok, profile_2} = Test.Fixtures.profile() |> Core.People.register_profile()
{:ok, profile_3} = Test.Fixtures.profile() |> Core.People.register_profile()
profiles = Core.People.list_profiles()
assert Enum.map(profiles, & &1.id) == [profile_1.id, profile_2.id, profile_3.id]
end
end
end
When the above test fails, the output is not the most wonderful thing in the world. Were
the profiles returned in the wrong order? Is there test pollution (or a setup
block added
later at the wrong scope), and some other id appears? Did you accidentally copy and paste
the wrong module name into the implementation of list_profiles
, and some entirely other
set of ids is returned?
A common refactoring step is to assert on some human-readable attribute from the test fixtures.
defmodule Core.PeopleTest do
use Test.DataCase, async: true
# ...
describe "list_profiles" do
test "lists all profiles" do
Test.Fixtures.profile(name: "Alice") |> Core.People.register_profile()
Test.Fixtures.profile(name: "Billy") |> Core.People.register_profile()
Test.Fixtures.profile(name: "Cindy") |> Core.People.register_profile()
profiles = Core.People.list_profiles()
assert Enum.map(profiles, & &1.name) == ~w[Alice Billy Cindy]
end
end
end
There's something that bothers me about this test, though: why do I care what names are used by the profiles? I care about entities returned by the funtion, not the specific attributes set on those entities.
There's another issue: chances are that a unique constraint may be later added to the email
field. When that happens, the above test for listing profiles will fail: even though we're using
names to uniquely identify the profiles, our fixture function defaults each email to
[email protected]
, violating our uniqueness constraint. If I also add unique emails to
each registered profile in each test, I begin to lose the utility of my fixture functions.
What if we could solve both problems at once: uniquely identify profiles in tests, while also generating unique values in fixtures?
Introducing test ids
Test ids are a concept that was introduced to me by Erik Hanson a few years ago. The idea is that a column can be added to each database table and Ecto schema; this column can be used to identify specific entities in tests regardless of their other attributes.
I'll admit that I was initially skeptical. Alter my database schema in a way that is only ever
used in tests???? What if... someone put data there? What if??? ... Honestly, I don't even
remember the arguments anymore. I've heard other people make those same arguments over the years,
balking at any hint of letting tests leak into their database layer. Some of those same people:
proposed introducing dependency injection service layers into our applications, so that code
interacting with external services could swap out the integration layer with test versions; added
argument after argument to function definitions, so that any module depending on another
module (DateTime
for instance) could have a mock injected in tests; tried to replace the entire
database layer with an in-memory fake, so that tests would not actually have to interact with
PostgreSQL.
If we write tests for our code, we make changes and compromises to that code to more easily easily test it. Why not extend that principle to the database layer, so that our tests can be cleaner and more consistent?
defmodule Core.Repo.Migrations.AddTidToProfiles do
use Ecto.Migration
def change do
create table(:profiles) do
add :tid, :string
end
end
end
defmodule Core.People.Profile do
use Core.Schema
schema "profiles" do
# ...
field :tid, :string
# ...
end
# ... def changeset
end
defmodule Test.Fixtures do
@type test_id() :: String.t() | atom()
@spec profile(test_id(), Enum.t()) :: map()
def profile(tid, attrs \\ %{}) do
%{
name: tid |> to_name() |> uniquify()
email: uniquify(tid, "<%= string %>-<%= seq %>@example.com"),
password: valid_profile_password(),
tid: to_string(tid)
}
|> merge!(attrs, %Core.People.Profile{})
end
def valid_profile_password, do: "some-long-password"
# # #
defp to_name(atom_or_string),
do: atom_or_string |> to_string() |> String.capitalize()
defp uniquify(string, format \\ "<%= string %><%= seq %>"),
do: EEx.eval_string(format, string: string, seq: System.unique_integer([:positive, :monotonic]))
# "Merge `overrides` into `defaults`, validating keys in `schema` struct, then convert into a map"
defp merge!(defaults, overrides, schema) do
defaults
|> Map.merge(Enum.into(overrides, %{}))
|> then(fn map -> struct!(schema, map) end)
|> Map.from_struct()
|> Map.delete(:__meta__)
end
end
- Our
uniquify
function is a touch more complicated than necessary. Our actual application uses a few string formats, so I've left it in this example. A simpler implementation could be:
string <> to_string(System.unique_integer([:positive, :monotonic]))
Now our tests can be rewritten to take advantage of the test ids:
defmodule Core.PeopleTest do
use Test.DataCase, async: true
describe "register_profile" do
test "saves a profile" do
assert {:ok, profile} =
Core.People.register_profile(name: "Alice", email: "[email protected]")
assert profile.name == "Alice"
assert profile.email == "[email protected]"
end
end
describe "get_profile_by_email" do
test "finds a profile by email" do
{:ok, _} = Core.People.register_profile(:alice)
assert profile = Core.People.get_profile_by_email("[email protected]")
assert profile.tid == "alice"
end
end
describe "list_profiles" do
test "lists all profiles" do
Test.Fixtures.profile(:alice) |> Core.People.register_profile()
Test.Fixtures.profile(:billy) |> Core.People.register_profile()
Test.Fixtures.profile(:cindy) |> Core.People.register_profile()
assert Core.People.list_profiles() |> tids() == ~w[alice billy cindy]
end
end
@spec tids(list()) :: [binary()]
def tids(enum), do: Enum.map(enum, & &1.tid)
end
The tids/1
function will quickly find itself spreading through your codebase after introducing
test ids. It can be extracted to a module and imported into Test.DataCase
.
The usefulness of this pattern may not be apparent from the two tests shown above... imagine this pattern applied to an application with hundreds or thousands of unit tests, and you start to get the sense of how much test code can be cleaned up. Where this pattern really shines is in conn tests, however, where test ids can concisely scope HTML finders and assert on identity of rendered entities. We'll return to that later.
Test tags
A feature of ExUnit
that I have only recently begun to make extensive use of is
tags. Before 2022, the most
I had used this feature for was @tag :skip
, with the occasional @tag :focus
, run via
mix test --only focus
. More recently, I have used test tags to isolate sections of tests
to only be run when shipping changes to CI.
For instance we can configure our tests to exclude all tests tagged as :external
:
ExUnit.configure(exclude: [external: true])
We can then have a test that interacts with the real S3 API.
defmodule Core.RemoteFile do
use Test.SimpleCase, async: true
alias Core.RemoteFile
@tag :external
test "uploads to s3" do
remote_filename = "#{System.system_time()}.jpg"
remote_path = Path.join("test", remote_filename)
local_path = "test/support/fixtures/test.jpg"
{:ok, s3_signature} = RemoteFile.s3_signature(remote_path, remote_mime_type, 2000)
assert :ok = RemoteFile.s3_upload(local_path, remote_filename, signature, "http://localhost:4000/")
# ... ExAws.S3.list_objects() |> assert_eq(...)
end
end
Our test command in CI can change from mix test
to mix test --include external:true
.
Using tags for test setup
As the number of schemas in our application has grown, with relationship between those schemas, the setup for many of our tests grew in complexity. An initial refactor extracted our fixture creation into setup blocks.
defmodule Core.PeopleTest do
use Test.DataCase, async: true
# ...
describe "list_profiles" do
setup do
Test.Fixtures.profile(:alice) |> Core.People.register_profile()
Test.Fixtures.profile(:billy) |> Core.People.register_profile()
Test.Fixtures.profile(:cindy) |> Core.People.register_profile()
:ok
end
test "lists all profiles" do
assert Core.People.list_profiles() |> tids() == ~w[alice billy cindy]
end
end
end
For relationships involving multiple entities, involving multiple profiles and authorization
logic, setup for a single true/false
test began to explode even with these extractions:
defmodule Core.PeopleTest do
use Test.DataCase, async: true
# ...
describe "administrator?" do
setup do
{:ok, superuser} = create_superuser()
{:ok, organization} = Test.Fixtures.org(:cal) |> Core.People.create_org()
{:ok, alice} = Test.Fixtures.profile(:alice) |> Core.People.register_profile()
:ok = Core.People.add_membership(organization, alice)
:ok = Core.People.change_role(organization, alice, :administrator, by: superuser)
[alice: alice, org: organization]
end
test "is true for an admin", %{alice: alice, org: organization} do
assert Core.People.administrator?(alice, organization)
end
end
end
Note: this is a normal experience for any application worked on for any length of time. We found ourselves dissatisfied, however, especially as we found ourselves writing groups of tests with slight variations of data. A single describe block might include three or four variations of the above setup, only one or two lines of which could be shared between tests.
Instead we decided to try out test tags for fixture setup, beginning with profiles:
defmodule Test.DataCase do
use ExUnit.CaseTemplate
import Test.SharedSetup
using do
quote do
import Moar.Sugar
import Test.DataCase
end
end
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Core.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
:ok
end
setup [:handle_profile_tags]
# ...
end
defmodule Test.SharedSetup do
def handle_profile_tags(tags) do
profile_tids = Map.get(tags, :profile) |> List.wrap() |> Enum.reject(&Moar.Term.blank?/1)
profiles = profile_tids |> Enum.reduce(%{}, &create_profile/2)
[profiles: profiles]
end
defp create_profile(profile_tid, profile_acc) do
profile_attrs = Test.Fixtures.profile(profile_tid)
{:ok, profile} = profile_attrs |> Core.People.register_profile()
Map.put(profile_acc, profile_tid, profile)
end
end
This allows us to tag specific tests with the profile(s) we would like to have available.
The tag is used as the tid
, which is then used as a key within the :profiles
map in the
test context.
defmodule Core.PeopleTest do
use Test.DataCase, async: true
# ...
describe "get_profile_by_email" do
@tag profile: :alice
test "finds a profile by email", %{profiles: %{alice: alice}} do
assert profile = Core.People.get_profile_by_email(alice.email)
assert profile.tid == "alice"
end
end
describe "list_profiles" do
@tag profile: [:alice, :billy, :cindy]
test "lists all profiles" do
assert Core.People.list_profiles() |> tids() == ~w[alice billy cindy]
end
end
end
What about complex relationships? For those, we can use keyword lists to map the tid
s of
the various entities, and add functions such as :handle_org_tags
and :handle_admin_tags
to
our Test.DataCase
.
defmodule Core.PeopleTest do
use Test.DataCase, async: true
# ...
describe "administrator?" do
@tag profile: :alice, org: :cal, admin: [alice: :cal]
test "is true for an admin", %{profiles: %{alice: alice}, orgs: %{cal: cal}} do
assert Core.People.administrator?(alice, cal)
end
@tag profile: :alice, org: :cal
test "is false when the profile does not administer the org",
%{profiles: %{alice: alice}, orgs: %{cal: cal}} do
refute Core.People.administrator?(alice, cal)
end
end
end
In our setup function we are using Core.People.register_profile/1
. Our org setup function
calls a function Core.People.create_org/1
. For unit tests of those functions, or of functions
related to linking them together (making membership or administration records, for instance),
we would not use this tag pattern, instead specifying any data variations clearly in each
test.
With confidence in the underlying functions, however, this pattern consolidates test setup into a small number of lines while retaining readability.
Page pattern
Others have written about the page object pattern for structuring tests that interact with and/or make assertions on UI elements. In Elixir we do not have objects, but we can develop a functional equivalent, keeping state between requests in structs.
Erik and I have developed Phoenix applications using this page pattern for a number of years, both separately and together. In our most recent work, we started over from the ground up, eventually releasing pages and html_query as stand-alone libraries to facilitate applying this pattern across projects. The tests shown from here on out will also refer to moar for pipe-able test helpers.
Caveat: all of our recent work has been 100% LiveView, tested via
Phoenix.LiveViewTest
, so pages may or may not be feature-complete for other use-cases at the time I write this. It is designed to provide multiple drivers for different test implementations, so please consider contributing to it if you're using a different test setup.
For our application with profiles, our page modules could begin with the following:
defmodule Test.Pages.LoginPage do
import Moar.Assertions
alias HtmlQuery, as: Hq
@spec assert_here(Pages.Driver.t()) :: Pages.Driver.t()
def assert_here(%Pages.Driver.LiveView{} = page) do
page
|> Hq.find("[data-page]")
|> Hq.attr("data-page")
|> assert_eq("login", returning: page)
end
def login(page, email, password) do
page
|> assert_here()
|> submit_form(%{email: email, password: password})
end
@spec submit_form(Pages.Driver.t(), Enum.t()) :: Pages.Driver.t()
def submit_form(%Pages.Driver.LiveView{} = page, attrs),
do: page |> Pages.submit_form([test_role: "login-form"], :profile, attrs)
@spec visit(Pages.Driver.t()) :: Pages.Driver.t()
def visit(%Pages.Driver.LiveView{} = page), do: page |> Pages.visit("/login")
end
defmodule Test.Pages.ProfilePage do
import Moar.Assertions
alias HtmlQuery, as: Hq
@spec visit(Pages.Driver.t()) :: Pages.Driver.t()
def visit(page),
do: page |> Pages.visit("/profile")
@spec assert_here(Pages.Driver.t()) :: Pages.Driver.t()
def assert_here(%Pages.Driver.LiveView{} = page) do
page
|> Hq.find("[data-page]")
|> Hq.attr("data-page")
|> assert_eq("profile", returning: page)
end
end
We have found it useful to organize test pages into modules specific to each actual page.
- lib/
- web/
- live/
- login_live.ex
- profile_live.ex
- test/
- web/
- live/
- login_live_test.exs
- profile_live_test.exs
- support/
- pages/
- login_page.ex
- profile_page.ex
Our profile test could begin as follows:
defmodule Web.ProfileLiveTest do
use Test.ConnCase, async: true
test "shows profile info", %{conn: conn} do
{:ok, alice} = Test.Fixtures.profile(:alice) |> Core.People.register_profile()
conn
|> Pages.new()
|> Test.Pages.LoginPage.visit()
|> Test.Pages.LoginPage.login(alice.email, alice.password)
|> Test.Pages.ProfilePage.visit()
|> Test.Pages.ProfilePage.assert_here()
|> Test.Pages.ProfilePage.assert_profile_info(alice.name, alice.email)
end
end
You'll notice a reference to [data-page]
. This can be made to pass by adding the following
to lib/web/layout/live.html.heex
.
<main class="container" id={@page_id <> "-page"} data-page={@page_id}>
<%= @inner_content %>
</main>
This will make each of the tests raise with a missing assigns error; page_id
can
be added to mount
in each LiveView module:
defmodule Web.ProfileLive do
use Web, :live_view
import Moar.Sugar
# ... render
def mount(_params, _session, socket) do
socket
|> assign(page_id: "profile")
|> ok()
end
end
Why read attributes from the page, rather than assert on routes? For LiveView pages using LiveViewTest helpers in our page driver, we could rely on exceptions to know that an error has occurred. I have experienced too many cases where a page did not load correctly in a test, but where no raised exceptions are registered by the test process, to trust that just because a page load fails its tests will fail.
Test roles
@spec submit_form(Pages.Driver.t(), Enum.t()) :: Pages.Driver.t()
def submit_form(%Pages.Driver.LiveView{} = page, attrs),
do: page |> Pages.submit_form([test_role: "login-form"], :profile, attrs)
You may have also noticed a selector in the Test.Pages.LoginPage
module:
test_role: "login-form"
. This is an important detail for test longevity. Imagine the
following LiveView module:
defmodule Web.AuthLive do
use Web, :live_view
import Web.Components
@impl Phoenix.LiveView
def render(%{live_action: :login} = assigns) do
~H"""
<section>
<h2 test-role="page-title">Log In</h2>
<.form
action={Web.Paths.login()}
as={:profile}
for={@changeset}
id="login-form"
class="my-form"
test-role="login-form"
let={f}
phx-change="change-login"
phx-submit="login"
phx-trigger-action={@trigger_submit}
>
<.text_field id="email-field" f={f} field={:email} title="Email" />
<.password_field id="password" f={f} field={:password} title="Password" />
<%= submit "Log in", is: "lock-on-submit" %>
</.form>
</section>
"""
end
# ... mount, handle_event, etc
end
It's common to see test code target elements via id
or class
attributes. This leads to
problems; it's extremely annoying to rename a handful of HTML classes as a part of a
redesign, to discover upon shipping the change that half of the test suite fails as a result.
Tighly coupling CSS to tests makes it difficult to change CSS in the future.
Instead, consider adding test-
attributes. After having gone through multiple
iterations, including data-role
, we prefer test-role
, test-id
, etc, for the reason
that I use data-
attributes to target elements from or pass information to Javascript.
If these same attributes are used in tests, then my test selectors are tightly coupled to
that Javascript code, and changing attributes for one use-case can break the other use case.
Test identifiers
With tid
s available, we can add them to pages as test-id
attributes on elements. Thus
we can test against identity, without having to track the specific unique names or emails
generated by our fixtures.
defmodule Web.MembershipLiveTest do
use Test.ConnCase, async: true
test "lists all profiles", %{conn: conn} do
{:ok, superuser} = create_superuser()
{:ok, org} = Test.Fixtures.org(:cal) |> Core.People.create_org()
{:ok, alice} = Test.Fixtures.profile(:alice) |> Core.People.register_profile()
{:ok, billy} = Test.Fixtures.profile(:billy) |> Core.People.register_profile()
_ = Core.People.add_to_org(alice, org, by: superuser)
_ = Core.People.add_to_org(billy, org, by: superuser)
conn
|> Pages.new()
|> Test.Pages.LoginPage.visit()
|> Test.Pages.LoginPage.login(superuser.email, superuser.password)
|> Test.Pages.MembershipPage.visit()
|> Test.Pages.MembershipPage.assert_here()
|> Test.Pages.MembershipPage.assert_profile_tids(~w[alice billy])
end
end
Pages + tags
For conn tests, we can combine our page pattern with our test tag pattern, to set up one or more pages in the test context.
defmodule Test.ConnCase do
use ExUnit.CaseTemplate
import Test.SharedSetup
using do
quote do
import Plug.Conn
import Phoenix.ConnTest
alias Web.Router.Helpers, as: Routes
@endpoint Web.Endpoint
end
end
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Core.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
setup [
:handle_profile_tags,
# ... all of my other tag functions
]
setup %{conn: conn} = tags do
profiles = Map.get(tags, :profiles)
page_tid = Map.get(tags, :page, [:logged_out])
pages =
page_tid
|> List.wrap()
|> Enum.map(fn
:logged_out ->
{:logged_out, Pages.new(conn)}
tid ->
profile = Map.fetch!(profiles, tid)
password = Test.Fixtures.valid_profile_password()
page =
conn
|> Pages.new()
|> Test.Pages.LoginPage.visit()
|> Test.Pages.LoginPage.login(profile.email, password)
{tid, page}
end)
|> Map.new()
[pages: pages]
end
end
This pulls our page setup out of each test, allowing us to compose various fixtures, and then log in with one or more of those fixtures.
defmodule Web.ProfileLiveTest do
use Test.ConnCase, async: true
@tag page: :logged_out
test "redirects to the login page", %{pages: %{logged_out: page}} do
page
|> Test.Pages.ProfilePage.visit()
|> Test.Pages.LoginPage.assert_here()
end
@tag page: :alice, profile: [:alice]
test "shows profile info", %{pages: %{alice: page}, profiles: %{alice: alice}} do
page
|> Test.Pages.ProfilePage.visit()
|> Test.Pages.ProfilePage.assert_here()
|> Test.Pages.ProfilePage.assert_profile_info(alice.name, alice.email)
end
@tag page: [:alice, :billy], profile: [:alice, :billy]
test "shows profile info for the logged-in profile",
%{pages: %{alice: alice_page, billy: billy_page}, profiles: %{alice: alice, billy: billy}} do
alice_page
|> Test.Pages.ProfilePage.visit()
|> Test.Pages.ProfilePage.assert_here()
|> Test.Pages.ProfilePage.assert_profile_info(alice.name, alice.email)
billy_page
|> Test.Pages.ProfilePage.visit()
|> Test.Pages.ProfilePage.assert_here()
|> Test.Pages.ProfilePage.assert_profile_info(billy.name, billy.email)
end
end
Hopefully one or more of these tricks are helpful for you. If you find any of our open source projects confusing or lacking in features, let us know via GitHub issues.