Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Acceptable Upgrades

Acceptable Upgrades

How do you upgrade a legacy application written around a legacy database—databases designed with best practices from the 90s, with UML looking somewhere between abstract expressionism and piles of spaghetti? …But keep the tests small and understandable, please!

In my experience, people write tests in 3 cases: they’re too stubborn not to; their problems are too complex to solve without them; or their setup is so understandable that there’s no reason not to. When working on large untested applications, it’s very easy to skip tests—there’s too much precedent to want to slow down and fight against the current.

Here, I will share patterns and experiments that help to write tests faster—and show that with good tooling you can fight upstream to make it so that you can maintain systems and update them with confidence.

https://codebeamamerica.com/talks/acceptable-upgrades/

Avatar for Eric Saxby

Eric Saxby

March 10, 2025
Tweet

More Decks by Eric Saxby

Other Decks in Programming

Transcript

  1. • Tests required a full production database dump • Tests

    took 5 - 10 minutes • Changing code was slow. Writing tests was slow. • New tests meant shipping code was slower.
  2. • “Well-factored” for one use-case may be poorly-
 factored for

    another • “Well-factored” may change over time • Rules are proxies for larger principles • Drive towards discovering principles; change
 rules over time
  3. • My opinions (based on 20+ Elixir apps in 6

    years) • Strong opinions held loosely • Don’t let exceptions to rules keep you from shipping • Treat exceptions as problem statements
  4. We can start to introduce domain-speci fi c context modules…

    With submodules… But what is a context?
  5. PageController calls Pro fi le functions PageController calls People functions

    Mailer calls Router veri fi ed_routes PageController imports Ecto.Query, fi nds
 Pro fi le by params
  6. Web can call Core.People… If Web calls Core.People.Pro fi le,

    I should see an error.
 
 If Web calls Core.Repo, I should see an error.
  7. Keep the <app> namespace, but without real code… Finances.Application, named

    modules started as processes but never referred to from outside of Finances.
  8. Functional core of application. Depend on Schema. Might depend on

    Etc, Extra, but NOT Web. Exports some 2nd-level modules, but NOT Repo.
  9. One- or two- module interfaces that are not Core, i.e.

    `Etc.S3` or `Etc.Otel`. Probably can only call itself… maybe Extra or library code.
  10. Develop libraries in-situ. Declare strict boundaries, so you can’t accidentally

    basghetti-fy code with calls into application-speci fi c Core or Web modules.
  11. describe "create_profile" do test "creates a profile" do # ...

    assert? # ... some data? # ... creates profile? end end
  12. describe "create_profile" do test "creates a profile" do assert {:ok,

    %Schema.Profile{}} = # ... some data? |> Core.People.create_profile() end end
  13. describe "create_profile" do test "creates a profile" do assert {:ok,

    %Schema.Profile{}} = Test.Fixtures.profile(:alice) |> Core.People.create_profile() end end
  14. describe "create_profile" do test "creates a profile" do assert {:ok,

    %Schema.Profile{}} = Test.Fixtures.profile(:alice, name: "Alice Ant") |> Core.People.create_profile() end end
  15. defmodule Test.Fixtures do def profile(tid, attrs \\ []) do %{

    name: name(tid), tid: to_string(tid), timezone: "Etc/UTC" } |> merge!(attrs, Schema.Profile) end # # # defp merge!(defaults, overrides, schema_module) do defaults |> Map.merge(Map.new(overrides)) |> Moar.Map.validate_keys!(struct(schema_module)) end end tid = test ID Clear identi fi er for tests
  16. setup do {:ok, profile} = Test.Fixtures.profile(:alice) |> Core.People.create_profile() {:ok, org}

    = Test.Fixtures.org(:ochre) |> Core.Orgs.create_org() {:ok, _} = Test.Fixtures.membership(:alice_ochre) |> Core.Orgs.create_membership(org, profile, ...) {:ok, region} = Test.Fixtures.region(:rabbit) |> Core.Orgs.create_region() {:ok, tax} = Test.Fixtures.tax(:thumb) |> Core.Budgets.create_tax(region, ...) {:ok, budget} = Test.Fixtures.budget(:bison) |> Core.Budgets.create_budget(tax, ...) # ... etc etc etc [profile: profile, org: org, tax: tax, budget: budget] end
  17. describe "create_profile" do test "creates a profile" do assert {:ok,

    %Schema.Profile{}} = Test.Fixtures.profile(:alice, name: "Alice Ant") |> Core.People.create_profile() end end
  18. describe "create_email" do test "creates an email belonging to a

    profile", %{profiles: %{alice: profile}} do assert {:ok, %Schema.Email{}} = Test.Fixtures.email(:alice, profile_id: profile.id) |> Core.People.create_email() end end
  19. describe "create_email" do test "creates an email belonging to a

    profile", %{profiles: %{alice: profile}} do assert {:ok, %Schema.Email{}} = Test.Fixtures.email(:alice) |> Map.put(:profile_id, profile.id) |> Core.People.create_email() end end
  20. describe "create_email" do test "creates an email belonging to a

    profile", %{profiles: %{alice: profile}} do assert {:ok, %Schema.Email{}} = Test.Fixtures.email(:alice) |> Core.People.create_email(profile) end end
  21. def create_email(attrs, %Schema.Profile{} = profile) do attrs |> new_email(profile) |>

    Core.Repo.insert() end def new_email(attrs \\ [], %Schema.Profile{} = profile) do # ... changeset end
  22. def create_email(%Schema.Profile{} = profile, attrs) do profile |> new_email(attrs) |>

    Core.Repo.insert() end def new_email(%Schema.Profile{} = profile, attrs \\ []) do # ... end
  23. describe "create_email" do test "creates an email belonging to a

    profile", %{profiles: %{alice: profile}} do assert {:ok, %Schema.Email{}} = profile |> Core.People.create_email(Test.Fixtures.email(:alice)) end end
  24. use Magritte describe "create_email" do test "creates an email belonging

    to a profile", %{profiles: %{alice: profile}} do assert {:ok, %Schema.Email{}} = Test.Fixtures.email(:alice) |> Core.People.create_email(profile, ...) end end
  25. • An internal functional API for your
 project • A

    boundary between Core and other
 namespaces • The current state of understanding of
 your business domain
  26. use Ecto.Schema schema "profiles" do field :name, :string field :seq,

    :integer, read_after_writes: true field :tid, :string field :timezone, :string has_many :keys, Schema.Key timestamps() end
  27. use Ecto.Schema schema "profiles" do field :name, :string field :seq,

    :integer, read_after_writes: true field :tid, :string field :timezone, :string has_many :keys, Schema.Key timestamps() end data structure
  28. @required_attrs ~w[name]a @optional_attrs ~w[tid timezone]a def changeset(data \\ %Profile{}, attrs)

    do data |> change(Map.new(attrs), @required_attrs ++ @optional_attrs) |> validate_required(@required_attrs) end
  29. @required_attrs ~w[name]a @optional_attrs ~w[tid timezone]a def changeset(data \\ %Profile{}, attrs)

    do data |> change(Map.new(attrs), @required_attrs ++ @optional_attrs) |> validate_required(@required_attrs) end data mutation
  30. import Ecto.Query from(_ in Profile, as: :profiles) |> where([profiles: p],

    ilike(p.name, ^name)) |> order_by([profiles: p], asc: p.name, asc: p.seq)
  31. import Ecto.Query from(_ in Profile, as: :profiles) |> where([profiles: p],

    ilike(p.name, ^name)) |> order_by([profiles: p], asc: p.name, asc: p.seq) data querying
  32. Ecto schemas go in their own namespace. De fi ne

    structure of data. No functions except for those directly related to data structure.
  33. defmodule Core.People do def create_email(%Schema.Profile{} = profile, attrs) do profile

    |> new_email(attrs) |> Core.Repo.insert() end def new_email(%Schema.Profile{} = profile, attrs \\ []) do # ... end end
  34. defmodule Core.People do def create_email(%Schema.Profile{} = profile, attrs) do profile

    |> new_email(attrs) |> Core.Repo.insert() end def new_email(%Schema.Profile{} = profile, attrs \\ []) do Core.People.Profile.changeset_for_create(profile, attrs) end end
  35. defmodule Core.People do # ... def change_email(%Schema.Email{} = email, attrs

    \\ []) do Core.People.Profile.changeset_for_update(email, attrs) end def update_email(%Schema.Email{} = email, attrs) do email |> change_email(attrs) |> Core.Repo.update() end end
  36. defmodule Core.People.Profile do import Ecto.Changeset @required_attrs ~w[address profile_id]a @optional_attrs ~w[tid]a

    def changeset_for_create(%Schema.Profile{id: profile_id}, attrs) do %Schema.Email{} |> cast(Map.new(attrs), @required_attrs ++ @optional_attrs) |> put_change(:profile_id, profile_id) |> validate_email() |> validate_required(@required_attrs) end # ... validate_email end
  37. defmodule Core.People do import Ecto.Query # ... def find_profile_by_email(address_fragment) do

    from(_ in Schema.Profile) |> join([p], assoc(p, :emails)) |> where([_, e], ilike(e.address, ^address_fragment)) |> Core.Repo.all() end end
  38. defmodule Core.People do alias Core.People # ... def find_profile_by_email(address_fragment) do

    People.Profile.Query.default_order() |> People.Profile.Query.join_emails() |> People.Email.Query.address_like(address_fragment) |> Core.Repo.all() end end
  39. defmodule Core.People.Profile do # ... defmodule Query do import Ecto.Query

    def base, do: from(Schema.Profile, as: :profiles) def default_order(query \\ base()), do: order_by(query, [profiles: p], asc: p.name) def join_emails(query \\ default_order()), do: join(query, :inner, [profiles: p], assoc(p, :emails), as: :emails) end end
  40. defmodule Core.People.Profile do # ... defmodule Query do import Ecto.Query

    def base, do: from(Schema.Profile, as: :profiles) def default_order(query \\ base()), do: order_by(query, [profiles: p], asc: p.name) def join_emails(query \\ default_order()), do: join(query, :inner, [profiles: p], assoc(p, :emails), as: :emails) end end Name your bindings! Always use plural binding names!
  41. defmodule Core.People.Email do # ... defmodule Query do import Ecto.Query

    def base, do: from(Schema.Email, as: :emails) def default_order(query \\ base()), do: order_by(query, [emails: e], asc: e.address) def address_like(query \\ default_order(), pattern), do: where(query, [emails: e], ilike(e.address, ^"%#{pattern}%"))) end end Predictable binding names allow for composition across Query modules.
  42. defmodule Core.People do alias Core.People # ... def find_profile_by_email(address_fragment) do

    People.Profile.Query.default_order() |> People.Profile.Query.join_emails() |> People.Email.Query.address_like(address_fragment) |> Core.Repo.all() end end
  43. • Quickly see all ways that a schema is queried

    across the entire application. • Rapidly compose existing fragments into new queries. • Rule: DO NOT IMPORT Ecto.Query into Web. • Rule I break: DO NOT IMPORT Ecto.Query into context module.
  44. setup do {:ok, profile} = Test.Fixtures.profile(:alice) |> Core.People.create_profile() {:ok, org}

    = Test.Fixtures.org(:ochre) |> Core.Orgs.create_org() {:ok, _} = Test.Fixtures.membership(:alice_ochre) |> Core.Orgs.create_membership(org, profile, ...) {:ok, region} = Test.Fixtures.region(:rabbit) |> Core.Orgs.create_region() {:ok, tax} = Test.Fixtures.tax(:thumb) |> Core.Budgets.create_tax(region, ...) {:ok, budget} = Test.Fixtures.budget(:bison) |> Core.Budgets.create_budget(tax, ...) # ... etc etc etc [profile: profile, org: org, tax: tax, budget: budget] end
  45. def setup_all_the_things do {:ok, profile} = Test.Fixtures.profile(:alice) |> Core.People.create_profile() {:ok,

    org} = Test.Fixtures.org(:ochre) |> Core.Orgs.create_org() {:ok, _} = Test.Fixtures.membership(:alice_ochre) |> Core.Orgs.create_membership(org, profile, ...) {:ok, region} = Test.Fixtures.region(:rabbit) |> Core.Orgs.create_region() {:ok, tax} = Test.Fixtures.tax(:thumb) |> Core.Budgets.create_tax(region, ...) {:ok, budget} = Test.Fixtures.budget(:bison) |> Core.Budgets.create_budget(tax, ...) # ... etc etc etc [profile: profile, org: org, tax: tax, budget: budget] end setup [:setup_all_the_things]
  46. profile: [:alice, :billy], org: :ochre membership: [alice: :org, billy: :ochre]

    region: :rabbit, tax: [:thumb, :tooth], budget: [bison: :thumb] invoice: [ingot: {:bison, :ochre}] line_item: [lemur: :ingot, llama: :ingot, loris: :llama]
  47. @tag profile: [:alice, :billy], org: :ochre @tag membership: [alice: :org,

    billy: :ochre] @tag region: :rabbit, tax: [:thumb, :tooth], budget: [bison: :thumb] @tag invoice: [ingot: {:bison, :ochre}] @tag line_item: [lemur: :ingot, llama: :ingot, loris: :llama]
  48. @describetag profile: [:alice, :billy], org: :ochre @describetag membership: [alice: :org,

    billy: :ochre] @describetag region: :rabbit, tax: [:thumb, :tooth], budget: [bison: :thumb] @describetag invoice: [ingot: {:bison, :ochre}] @describetag line_item: [lemur: :ingot, llama: :ingot, loris: :llama]
  49. @moduletag profile: [:alice, :billy], org: :ochre @moduletag membership: [alice: :org,

    billy: :ochre] @moduletag region: :rabbit, tax: [:thumb, :tooth], budget: [bison: :thumb] @moduletag invoice: [ingot: {:bison, :ochre}] @moduletag line_item: [lemur: :ingot, llama: :ingot, loris: :llama]
  50. @moduletag profile: [:alice, :billy], org: :ochre @moduletag membership: [alice: :org,

    billy: :ochre] @moduletag region: :rabbit, tax: [:thumb, :tooth], budget: [bison: :thumb] @moduletag invoice: [ingot: {:bison, :ochre}] @moduletag line_item: [lemur: :ingot, llama: :ingot, loris: :llama] @tag line_item_attrs: [llama: [amount: ~USD"50.00"]]
  51. def setup_profiles(tags) do profile_tids = Map.get(tags, :profile, []) |> List.wrap()

    profiles = for profile_tid <- profile_tids, into: %{} do {:ok, profile} = Test.Fixtures.profile(profile_tid) |> Core.People.create_profile() {profile_tid, profile} end [profiles: profiles] end
  52. def setup_memberships(tags) do membership_tids = Map.get(tags, :membership, []) memberships =

    for {profile_tid, org_tid} <- membership_tids, into: %{} do membership_tid = "#{profile_tid}_#{org_id}" org = find_org!(tags, org_tid) profile = find_profile!(tags, profile_tid) {:ok, membership} = Test.Fixtures.membership(membership_tid) |> Core.People.create_membership(org, profile, ...) {membership_tid, membership} end [memberships: memberships] end
  53. defmodule Test.TagSetup do def fixtures, do: [ :setup_profiles, :setup_orgs, :setup_memberships,

    :setup_regions, :setup_taxes, :setup_budgets, :setup_line_items # ... ] # ... end
  54. defmodule Test.ConnCase do use ExUnit.CaseTemplate using do quote do import

    Test.TagSetup end end # ... setup Test.TagSetup.fixtures() end
  55. @tag profile: [:alice, :billy], org: :ochre @tag membership: [alice: :org,

    billy: :ochre] @tag region: :rabbit, tax: [:thumb, :tooth], budget: [bison: :thumb] @tag invoice: [ingot: {:bison, :ochre}] @tag line_item: [lemur: :ingot, llama: :ingot, loris: :llama] test "budgets a budget", %{ budgets: %{bison: bison}, line_items: %{lemur: lemur, llama: llama} } do assert lemur.budget_id == bison.id assert llama.budget_id == bison.id end
  56. @tag profile: [:alice, :billy], org: :ochre @tag membership: [alice: :org,

    billy: :ochre] @tag region: :rabbit, tax: [:thumb, :tooth], budget: [bison: :thumb] @tag invoice: [ingot: {:bison, :ochre}] @tag line_item: [lemur: :ingot, llama: :ingot, loris: :llama] test "budgets a budget", %{ budgets: %{bison: bison}, line_items: %{lemur: lemur, llama: llama} } do assert lemur.budget_id == bison.id assert llama.budget_id == bison.id end Singular declaration
 :budget Plural fi xtures: :budgets Clearly named entities
  57. • Tags declare relationships between named entities, where attrs are

    less important or inferred from names • Tags stay close to their tests, in comparison to very large setup functions • Tags are composable and extendable
  58. • Tag lists can get very large… but still much

    smaller than the comparable setup_all_the_things.
  59. alias Test.Page.Student.Application, as: Page test "can create new application", %{

    class: ~M{crane}, org: ~M{ochre}, pages: %{steph: page} } do page |> Page.visit(:new, crane, ochre) |> Page.assert_here(:new) |> Test.Page.refute_authorization_error() |> Page.assert_letters_of_recommendation([ %{"Recommender" => "Ralph", "Type" => "Main"}, %{"Recommender" => "Ralph", "Type" => "Other"} ]) |> Page.assert_selected_letters_of_recommendation([]) |> Page.select_letters_of_recommendation([“other”, "main"]) |> Page.assert_here(:edit) |> Page.assert_selected_letters_of_recommendation([“main", "other"]) end
  60. • Use Pages or PhoenixTest for abstracting di ff erence

    between LiveView and Controller tests.
  61. • Refactor and extract libraries to make development faster. •

    Discard patterns that make your life more di ffi cult.
  62. defmodule Web.Message do def invitation(token) do %Schema.Message{ subject: "Invitation to

    My Site", body: """ You have been invited to join My Site! This path is in the Web namespace: #{Web.Paths.invitation(token)} """ } end end @impl Phoenix.LiveView def handle_event( "submit-invitation", %{"invitation" => params}, socket ) do case Core.People.create_invitation(params) do {:ok, invitation} -> {:ok, _} = Web.Message.invitation(invitation.token) |> Core.People.send_message(invitation.address) socket |> push_navigate(to: Web.Paths.somewhere()) |> noreply() end end Still searching for a better pattern for enforcing boundaries in emails, but for now: