Planning Poker Project

2024-11-15

This post describes development and testing of a responsive agile planning poker application, implemented using Elixir, Phoenix and Phoenix LiveView and tested using Playwright and TypeScript

Introduction

Estimating user stories is an important practice in an agile software development project. This post describes implementing a Planning Poker application using Elixir and Phoenix LiveView and its testing using TypeScript and Playwright. The application can be found here.

Planning Poker Application

Audience

The first part of this post discusses the requirements for such an application and may be of interest to people leading planning sessions, such as scrum masters or product owners. For product owners, this is both from the planning session and product requirement point of view.

This post is also aimed at developers who are interested in learning how to build a real-time web application using Elixir and Phoenix LiveView. It assumes a basic knowledge of Elixir and Phoenix. Another audience section is people choosing the tech stack for future projects, as it demonstrates the capabilities of Elixir and Phoenix LiveView for building real-time web applications. Furthermore, this post shows how such an application can be tested from unit tests to end-to-end tests simulating multiple users using the Playwright test automation library with TypeScript.

The contents of the post can be divided into the following sections based on the interests of the readers:

Agile Estimations

Estimations help plan sprints by determining how many stories can fit into the upcoming sprint and ensuring the team commits to a manageable amount of work. Estimating stories accurately can be challenging, but the estimates improve and become more accurate as the project progresses. Estimations also help in long-term planning by predicting the time needed to deliver features and setting realistic release dates and milestones.

Overview of Planning Poker

Estimation in an agile software development project is a collaborative process that involves the team evaluating the complexity or the effort required to complete tasks, often represented as user stories. Estimation sessions encourage team members to discuss the stories, helping to clarify requirements, identify dependencies, and ensure everyone understands the work involved. It’s common for new things to come up in these discussions, making the story simpler or more complicated than initially thought.

Planning Poker is a popular technique used in Agile software development to estimate a user story. The session is typically facilitated by a Scrum Master or a Product Owner, who presents the stories to the team. Team members estimate stories one by one using numbers from the Fibonacci sequence or T-shirt sizes. Before the estimation, the person leading the session often presents the story to the team, and there may be an initial discussion if anything needs to be clarified. After estimating, the participants reveal their estimates at the same time to avoid anchoring bias and to encourage team members to think independently.

There can be several outcomes when the estimates are revealed:

  • The team is fully aligned and everyone gave the same estimate
  • Most of the team estimated the same but there were a couple of higher or lower estimate
  • The estimates are wildly different

In the first scenario, it’s easy to just pick the value everyone estimated. In the second case, sometimes the team is happy to decide on the estimate based on the average estimate and pick the closest value to it. Team members who estimated higher or lower get to explain their reasoning at this point and the team can discuss the discrepancies. The same process applies to the third scenario, and often the story needs to be re-estimated once the team members have explained their reasoning.

Requirements for a Planning Poker Tool

Based on the above description, we can at this point identify the following requirements for a minimum viable Planning Poker tool for the purposes of this post and project. The application will evolve in the future based on the feedback from users.

Planning Poker Tool Requirements
An Admin user can set up a planning session
An Admin can add stories to the planning session with a ticket ID and an optional description
An Admin can remove stories from the session
An Admin can invite team members to the session by copying the session URL
An Admin can see how many people have joined
An Admin can select which story to estimate
An Admin can reveal the estimates for a story
An Admin can reset the estimates for a story
An Admin can pick the estimate for the story after voting
A Team Member can join the session by opening the URL
A Team Member can give their estimate for a story
A Team Member can see that others have estimated without the points
A Team Member can see other members’ estimates after the Admin reveals them
Everybody can see a summary of the estimates with the average for all estimates
Everybody can see the selected estimate for previous stories
Everybody can see a summary of the session in a table with stories and estimates

In addition to the functional requirements, the application should also work in desktop and mobile devices.

Tech Stack for the Project

This project is built using the technologies shown in the table below.

Technology Purpose
Elixir Functional Programming Language
Phoenix Web Framework
Phoenix LiveView Provides Real-time UX with server-rendered HTML
Tailwind CSS CSS Framework
Playwright Library to automate browser-based testing
Fly.io Hosting

Phoenix LiveView is a real-time web framework built on top of the Elixir programming language and the Phoenix web framework. It is designed to simplify the development of interactive and real-time web applications without the need for extensive JavaScript code. These characteristics are an excellent match for an application where multiple people interact with it, and messages need to be sent back and forth to reflect the current session state.

LiveView leverages WebSockets for the communication between the server and the client. When the state on the server is updated, the required page updates are sent over WebSockets to the client. The updates are heavily optimised to minimise the amount of data sent over the wire keeping the application as fast as possible. All of this is available without having to build an API for data transfer.

Architecture

In the book Designing Elixir Systems with OTP, Bruce Tate and James Edward Gray describe a layered architecture for Elixir applications. We will use this pattern here and adapt it to the requirements of the Planning Poker application. The architecture follows a clear separation of concerns while taking advantage of Elixir's strengths in concurrent processing and real-time web capabilities.

Application Layer Summary

The data layer models the domain, which contains data used in a planning session. On top of that, we have a functional core, which defines the functions that transform the data as required by the application behaviour.

The next level up is the server process layer, which manages the application state. In our case, we will have a server process for each session to keep track of the session data and the connected clients. Processes here refer to lightweight Elixir processes. An important part of an Elixir (and Erlang) application is the supervisor, which starts and monitors the processes required by the application, restarting when necessary. This application will not have a database at this point, and the session data won’t be stored after the session is over.

Finally, we have the web interface, which will be implemented using Phoenix LiveView. This will allow us to create a real-time web application with minimal JavaScript/TypeScript, allowing to add more interactivity using the JS ecosystem should it be required later on.

Tests can be considered a separate application part and are written for each layer as suggested in the test pyramid. In addition to the Elixir tests, a BDD-style acceptance test suite will be written using Playwright.

The architecture can be summarized as a four-layer system:

Data Layer

  1. Foundation of the application
  2. Models the domain
  3. Contains data structures for planning sessions

Functional Core Layer

  1. Contains pure functions
  2. Handles data transformations
  3. Implements core business logic

Server Process Layer

  1. Manages application state
  2. Uses Elixir/OTP processes
  3. Includes supervisor hierarchy
  4. Maintains session data and client connections

Web Interface Layer

  1. Built with Phoenix LiveView
  2. Handles real-time interactions
  3. Minimal JavaScript requirements
  4. Extensible for future JS integration

Testing

  1. Unit tests for each layer using Elixir’s test framework ExUnit
  2. LiveView Testing with the Phoenix LiveView testing tools
  3. End-to-end acceptance tests using Playwright with a BDD style approach
  4. Manual and user testing

Data Layer

Based on the description earlier, the application needs to have the following data models:

  1. Session - Represents a Planning Poker session. Can contain multiple Stories and Members
  2. Story - Represents a Story to be estimated. Can contain Estimates
  3. Estimate - Represents an estimate for a Story by a Member
  4. Member - Represents a user in the system

Many Elixir applications use Ecto to interact with data and databases. It can also handle data when a database is not used like in this application at the moment. With Ecto, we can define a schema containing the fields to the record with their types. An important Ecto feature are Changesets, which validates the provided data based on the defined rules before creating a new record.

Elixir is a dynamically typed language, but adding type information to the code is possible using the @spec and @type attributes. This is useful for documentation purposes and also for a type check tool like Dialyzer that can check the types of the code and warn of any mismatches.

The latest Elixir release, 1.17, includes gradual set-theoretic types that are used for type-check programs during compilation. More type system improvements will arrive in the next release 1.18. Many of these are explained by Jose Valim, the creator of Elixir, in this video

This is an example definition for a Session embedded schema with a type definition. Embedded schemas can use used when the data is not backed by a database, or when using a jsonb Postgres column type for a record and still wanting to have a schema definition. Some code left out for brevity.

defmodule PlanningPoker.Planning.Session do
  use Ecto.Schema

  alias __MODULE__
  alias PlanningPoker.Planning.Member
  alias PlanningPoker.Planning.Story

  @type t :: %__MODULE__{
          id: binary(),
          name: String.t(),
          description: String.t(),
          members: list(Member.t()),
          stories: list(Story.t()),
          start_time: DateTime.t(),
          end_time: DateTime.t(),
          expiry: DateTime.t(),
          status: atom(),
          auto_reveal: boolean()
        }

  @session_statuses [:setup, :active, :completed]

  @primary_key false
  embedded_schema do
    field(:id, :binary_id)
    field(:name, :string)
    field(:description, :string)
    field(:start_time, :utc_datetime, default: DateTime.utc_now(:second))
    field(:end_time, :utc_datetime)
    field(:expiry, :utc_datetime)
    field(:status, Ecto.Enum, values: @session_statuses, default: :setup)
    field(:auto_reveal, :boolean, default: false)

    embeds_many(:members, Member)
    embeds_many(:stories, Story)
  end

  @spec new(map()) :: {:ok, Session.t()} | {:error, Ecto.Changeset.t()}
  def new(attrs) do
    %Session{}
    |> changeset(attrs)
    |> cast_embed(:members, required: false)
    |> cast_embed(:stories, required: false)
    |> apply_action(:create)
  end

  def changeset(%Session{} = session, attrs) do
    session
    |> cast(attrs, [:id, :name, :description, :auto_reveal, :expiry])
    |> validate_required([:name])
    |> validate_length(:name, min: 5, max: 50)
    |> validate_length(:description, min: 5, max: 200)
    |> maybe_put_id()
  end
end

In the example above, we only allow the fields included in the cast call, require a session name and have validation for the length of the name and description for the session.

Schemas are the lowest level of data layer in typical Elixir applications, and on top of schemas, there are usually context modules defining the external API for interacting with the data. Context modules contain functions to handle related data in one place, for example, in this application, the Sessions context handles all of the data in this application as the Schemas are all related.

Functional Core

The functional core is the part of the application that contains the business logic, such as the functions to transform the data. In our case, we will have functions to add a story to a session, an estimate to a story, and so on. This part of the application is independent of the outside world and can be tested in isolation. This allows us to write tests for the core functions without needing to set up the whole application, or even to have a UI built. The tests will be fast, reliable, and easier to write.

An example function call that returns an updated session looks like this:

Sessions.update(session, %{name: "Updated name", description: "Updated description"})

The next example shows using the pipeline operator together with functions transforming the data. It is a powerful feature in Elixir that allows us to compose more complex operations by calling functions using Elixir pipelines. The result of the previous step will be the first argument for the next operation in the pipeline. The following example shows example code on how this can be used to setup a session in a test. Note: This is not how the application works or how you should use pipelines in the production. This example is for testing purposes and to showcase how pipelines work.

session =
  %{
 name: "Planning Session",
 description: "Session Description"
  }
  |> Sessions.create()
  |> Sessions.add_story(story_1)
  |> Sessions.add_story(story_2)
  |> Sessions.add_story(story_3)
  |> Sessions.add_member(alice)
  |> Sessions.add_member(bob)
  |> Sessions.add_member(jack)
  |> Sessions.set_story_to_active(story_1.id)
  |> Sessions.estimate_story(story_1.id, Estimate.new(alice.id, 5))
  |> Sessions.estimate_story(story_1.id, Estimate.new(bob.id, 8))
  |> Sessions.estimate_story(story_1.id, Estimate.new(jack.id, 5))
  |> Sessions.set_story_to_completed(story_1.id, 5)

  # Assertions about the session go here...

The tests for the business logic in the functional core are very fast and enable us to have good test coverage without spending too much time waiting for the tests to complete. From the testing perspective, when the business logic is tested on this level, it will not have to be tested or retested on a higher and slower level in the test pyramid. Instead, we can focus on testing the integration between the layers and end-to-end functionality. We assigned the transformed session data to the session variable in the code sample above. This data structure will need to be made available for the application users somehow.

Managing State in Elixir Applications

In the previous chapter, we worked with the session data as it flowed through our pipeline functions, but we didn't persist or manage that state. In a real application, we need to maintain session data and application state somewhere. This is where Elixir's server processes come in, specifically through the GenServer behaviour.

What is GenServer?

GenServer (Generic Server) is one of Elixir's core behaviours, providing a standard way to implement a server process. Think of it as a contract that defines how a process should handle state, respond to messages, and interact with other parts of your application. It's particularly useful when you need to:

  • Maintain state over time
  • Handle concurrent operations safely
  • Provide a clean interface for other parts of your application

A GenServer module consists of two main parts:

  1. Client API (Public Interface): Functions that other parts of your application call
  2. Server Callbacks: Functions that handle the actual state management and processing

Here's are some example functions of our SessionServer that manages planning poker sessions:

defmodule PlanningPoker.SessionServer do
  use GenServer
  alias PlanningPoker.{Session, Member}

  # Client API

  @doc """
  Starts a new session server process.
  """
  def start_link(session) do
    GenServer.start_link(__MODULE__, session, name: via_tuple(session.id))
  end

  @doc """
  Gets the current session.
  """
  @spec get_session(String.t()) :: Session.t()
  def get_session(session_id) do
    GenServer.call(via_tuple(session_id), :get_session)
  end

  @doc """
  Adds a member to the session.
  Returns the updated session state.
  """
  @spec add_member(String.t(), Member.t()) :: Session.t()
  def add_member(session_id, member) do
    GenServer.call(via_tuple(session_id), {:add_member, member})
  end

  # Server Callbacks

  @impl true
  def init(session) do
    {:ok, session, @timeout}
  end

  @impl true
  def handle_call({:add_member, member}, _from, session) do
    session = Sessions.add_member(session, member)
    {:reply, session, session, @timeout}
  end

  @impl true
  def handle_call(:get_session, _from, session) do
    {:reply, session, session, @timeout}
  end

  # Helper Functions

  # Returns the PID of the process we need to interact with.
  defp via_tuple(session_id) do
    {:via, Registry, {PlanningPoker.SessionRegistry, session_id}}
  end
end

Let's break down how this works:

Client API Functions

The client API functions are what other parts of your application call. They use GenServer.call/2 or GenServer.cast/2 to send messages to the server process:

# Synchronous call (waits for response)
def add_member(session_id, member) do
  GenServer.call(via_tuple(session_id), {:add_member, member})
end

# Asynchronous cast (fire and forget)
def notify_members(session_id, message) do
  GenServer.cast(via_tuple(session_id), {:notify_members, message})
end

Server Callbacks

The server callbacks handle the actual state management. They receive messages from the client API that are pattern matched ({:add_member, member}) to decide which function to use and return tuples that tell the GenServer how to proceed:

# Handles synchronous calls and returns a value
def handle_call({:add_member, member}, _from, session) do
  session = Sessions.add_member(session, member)
  {:reply, session, session, @timeout}
end

# Handles asynchronous casts, does not return a value
def handle_cast({:notify_members, message}, session) do
  # Process the message...
  {:noreply, session, @timeout}
end

The tuple returned by handle_call contains:

  • :reply - indicates we're sending a response
  • The value to return to the caller
  • The new state to store
  • An optional timeout value

In the first function, we get the current session state passed in the session variable, update the valiable by assigning the return value from the add member_ call and then return the new session as the new state. The second function, handle_cast does some processing, possibly with side effects, but does not return a value.

Using the SessionServer

A SessionServer process is created for each active session when the application starts. Other parts of the application can then interact with these processes through the client API when they know the session ID:

# Starting a new session
{:ok, session} = Session.new()
SessionServer.start_link(session)

# Adding a member
member = %Member{id: "123", name: "Alice"}
updated_session = SessionServer.add_member(session.id, member)

The GenServer ensures that all operations on the session state are handled sequentially and safely, preventing race conditions and maintaining data consistency.

In the next chapter, we'll see how to integrate this SessionServer with our Phoenix UI, allowing real-time updates and interaction with our planning poker sessions.

Building the Real-time Interface with Phoenix LiveView

The web interface represents the final layer of our Planning Poker application. This layer needs to solve two key challenges:

  • Providing an interactive user interface
  • Keeping all session participants synchronised with the server state

Phoenix LiveView: Server-Side UI with Real-time Updates

Phoenix is a server-side web framework that works similarly to Ruby on Rails or Laravel (PHP). It generates HTML on the server and sends it to the client. Phoenix LiveView is an extension that allows the server to push updates to the client in real time, making it possible to build interactive web applications without writing JavaScript.

For this application, it solves the challenges by maintaining the UI state on the server and efficiently synchronising it with connected clients when the state changes. Unlike traditional approaches that require separate frontend and backend codebases, LiveView allows the building of real-time features using just Elixir. It communicates over a WebSocket connection, sending optimised updates instead of using an API. There’s no need to build an API for the application, saving time and effort and not having to deal with integration issues.

How LiveView Works

LiveView establishes a WebSocket connection between the client and server, enabling:

  • Server-pushes updates to the client
  • Client events sent to the server
  • Efficient DOM patching (only changed elements are updated)

Each LiveView consists of two parts. The code that handles loading data and handling and responding to events, and the template code that the renders the current data. These can be in the same file or the template code can be split into a separate template file. For this application, the code sample below shows a shortened version of the LiveView for displaying a planning session.

defmodule PlanningPokerWeb.Live.Session.Show do
  use PlanningPokerWeb, :live_view
  alias PlanningPoker.SessionServer
  alias PlanningPoker.Planning.Estimate

  # The view is mounted when the user opens the URL. The mount functions loads the
  # initial data, sets up the LiveView state and subscribes to updates.
  def mount(%{"id" => session_id}, _session, socket) do
    if connected?(socket) do
      # Subscribe to updates for this session
      PlanningPokerWeb.Endpoint.subscribe("session:#{session_id}")

      # Track presence for this user
      {:ok, member} = track_presence(session_id, socket)

      # Get initial session state
      session = SessionServer.get_session(session_id)
      active_story = Sessions.get_active_story(session)

      {:ok, assign(socket,
        session: session,
        current_member: member,
        active_story: active_story
      )}
    end
  end

  # The UI events are handled with _handle_event_ functions. The function head
  # pattern matches the event name and parameters and updates the LiveView state.
  # In this case, it sets the value for revealed to true.
  def handle_event("reveal-estimates", _params, socket) do
    session_id = socket.assigns.session.id

    # Broadcast the event to all participants
    PlanningPokerWeb.Endpoint.broadcast(
      "session:#{session_id}",
      :reveal_estimates
    )

    {:noreply, assign(socket, revealed: true)}
  end

  # Another handle_event example pattern matches on the event name and parameters,
  # extracting the value for points from the event.
  def handle_event("estimate", %{"points" => points}, socket) do
    session_id = socket.assigns.session.id
    member_id = socket.assigns.current_member.id

    estimate = %Estimate{
      member_id: member_id,
      points: String.to_integer(points)
    }

    # Update session state
    session = SessionServer.estimate_story(
      session_id,
      socket.assigns.session.active_story_id,
      estimate
    )

    # Helper function defined outside of this module
    PlanningPoker.broadcast(session.id, :session_updated)

    {:noreply, assign(socket, session: session)}
  end

  # handle_info functions are used to handle messages sent to the LiveView process.
  # In this case, each connected LiveView is updated when they receive this event.
  # The event handler calls a function that
  # - sets revealed to true
  # - calculates the point average
  # - assigns the values the person leading the session can pick from
  def handle_info(:reveal_estimates, socket) do
    {:noreply, get_point_options(socket)}
  end

  # ... more functions
end

Phoenix Presence and PubSub

Two other very important parts of the Phoenix web framework that we will use in this application are Phoenix PubSub and Phoenix Presence. Phoenix PubSub is a publish/subscribe system that allows us to broadcast messages to all connected clients. Phoenix Presence keeps track of the clients connected to the server and allows us to see which clients are connected to a certain channel. We can then push this information to the clients using Phoenix PubSub.

LiveView Template

LiveView template code is written using HEEX templates, that combine HTML template code with embedded Elixir code. LiveView state is stored in socket.assigns, and can be accessed in the template as, for example, @session. When the value of the variable changes, the rendered page is updated. The following code shows how displaying of the estimates works in this application.

<div class="mt-6 md:mt-12">
  <%= if @active_story do %>
    <h3>
      Estimates
      <span :if={@revealed} class="font-light text-base text-gray-500 uppercase">
        (Average: <%= @average %>)
      </span>
    </h3>
    <div class="flex flex-wrap gap-4">
      <%= for index <- 0..placeholder_count(@session.members, @active_story.estimates), index > 0 do %>
        <.estimate_placeholder estimating={true} />
      <% end %>

      <.estimate
        :for={estimate <- @active_story.estimates}
        :if={@active_story}
        points={estimate.points}
        revealed={@revealed}
      />
    </div>
  <% else %>
    <h3>Participants</h3>
    <div class="flex flex-wrap gap-4">
      <%= for index <- 0..placeholder_count(@session.members, []), index > 0 do %>
        <.estimate_placeholder estimating={false} />
      <% end %>
    </div>
  <% end %>
</div>

The below example shows how an action is triggered from the UI. The action is defined in the phx-click attribute and doesn't require any other data to be passed with it. In some cases we need to pass an ID or a value together with the event. For that we can use a phx-value-<name> attribute. The example also shows how to disable the when the story has no estimates or if the estimates have already been revealed.

<.button
  phx-click="reveal-estimates"
  disabled={Enum.empty?(@active_story.estimates) || @revealed}
  title="Reveal estimates"
>
  Reveal
</.button>

After the action is triggered, the LiveView module will handle the event described earlier.

Testing Strategy

A project needs testing on multiple levels, as described in the test pyramid. The lowest level of testing is unit testing. We need to test the application’s low-level elements and then move on to higher levels, such as the LiveView UI and the business requirements.

Unit Testing

Testing the business logic in an application built with a functional core is quick and, most of the time, easy. There are unit tests on different levels. We need to test the schemas and the more complex and interesting context functions. These need to be tested for positive and negative cases.

Schemas

A positive test for a schema can look like the following:

test "#new creates a new Member" do
  {:ok, member} = Member.new("teammember1")
  assert member.name == "teammember1"
  assert member.role == :team_member
  assert %DateTime{} = member.joined_at
  assert member.left_at == nil
end

while test for a negative case can look like this:

test "#new returns an error when data is invalid" do
  {:error, changeset} =
    Session.new(%{
      name: "",
      description: "session description"
    })

  refute changeset.valid?
end

Context Functions

Context functions are used to update the state of a planning session and can interact with multiple types of schemas.

  test "#create creates a session" do
    {:ok, session} = Sessions.create(%{name: "Session name", description: "Session description"})
    assert session.name == "Session name"
    assert session.description == "Session description"
  end

  # or

  test "#add_story adds a story to the session", %{session: session} do
    assert session.stories == []
    {:ok, story} = Story.new(%{name: "TICKET-567"})
    session = Sessions.add_story(session, story)
    assert session.stories == [story]
  end

LiveView Testing

I wrote about LiveView testing earlier here. With LiveView testing, the application UI can be tested very quickly with the tools provided in the LiveView library itself.

LiveView tests are a great way to verify the application behaviour and the UI. The tests can be simple, just opening a page and checking that some content is present as shown below.

test "connected mount", %{conn: conn} do
  {:ok, view, html} = live(conn, "/sessions/new")
  assert html =~ "<h1>Start a New Session</h1>"
  assert has_element?(view, "#start-new-session")
end

For a more comprehensive example, the following test checks that a session is created when submitting the new session form and that the user is redirected to the correct page. It also checks that the SessionServer knows about the new session created and that the correct member joined it.

  test "it redirects to a new session when submitting the form", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/sessions/new")

    assert view
           |> element("#session-form")
           |> render_submit(%{
             name: "Test Session",
             description: "Test Session Description"
           })

    {path, _flash} = assert_redirect(view)
    assert path =~ ~r/sessions\/[\S]+/

    %{"session_id" => session_id} = Regex.named_captures(~r/sessions\/(?<session_id>[\S]+)/, path)
    session = SessionServer.get_session(session_id)
    assert length(session.members) == 1
    assert hd(session.members).role == :admin
  end

What is cool about this test is that it tests the application end-to-end, from accessing the UI to storing the updated state in the session server.

BDD Style Acceptance Testing

Applications are usually built based on specific business requirements. Ensuring these requirements are met is critical for the project’s success. Behavior-driven Development (BDD) and related tools are often used to improve communication between stakeholders and engineers and ensure the requirements are well-written and understood by both sides. The idea in BDD is first to define and describe the behaviour and then implement it.

How much Test Coverage is Enough?

While we can test most of the functionality using LiveView testing, it’s always comforting to have some tests running in an actual browser before releasing a new version of an application to the wild. This application does some things specific to browsers; for example, it stores the user ID in the localStorage so that we can keep the application state over page reloads and closing and reopening browser tabs. To thoroughly test this behaviour, we need to use a browser.

Another essential feature is the application’s interactivity. Multiple users participate in a planning session, and updates from other users should be visible to everyone.

Playwright

This is where Playwright comes in. Playwright is currently my go-to tool for automating browser interactions. It’s fast, reliable, has a nice API, and is backed by Microsoft, ensuring project continuity. Playwright has bindings in multiple languages but I’m using TypeScript in this project. The API is about the same for every language, so feel free to pick the one you like the most. There are also unofficial Elixir bindings here, but I haven’t had a chance to work with them yet.

How and what kind of tests should be implemented for this application? As always, when thinking about acceptance tests, the key is to consider the test scenarios from the user’s point of view. We listed the requirements earlier in this post, and the requirements table can serve as a basis for our test scenarios.

As we have already tested the lower-level code with unit tests and even some of the UI with LiveView tests, we won’t need many tests at the top level. Let’s see how to implement these tests using Playwright.

Testing with multiple users

Testing an application with multiple users interacting with a planning session requires multiple browsers to connect to the application and take action. Playwright is well suited for this task as it allows starting multiple browser sessions as different users. To simulate multiple users, we need to first set up a context, a context page and then initialise our page object.

// Context first
const adminContext = await browser.newContext();
const memberContext = await browser.newContext();

// Then pages for the contexts
const adminContextPage = await adminContext.newPage();
const memberContextPage = await memberContext.newPage();

// And the page itself as we are using page objects.
adminSessionPage = new SessionPage(adminContextPage);
memberSessionPage = new SessionPage(memberContextPage);

Page Objects

As you can see from the following piece of code, while adding selectors directly in the test code works fine, it may become cumbersome to maintain. Selectors are usually not very nice to read in the first place, and with many tests, some of them will have to access the same elements. When something on the page changes, the selectors need to be updated in all the places where they are referred to.

Example code with selectors defined directly in a test

await adminPage.locator('.pick-estimates [phx-value-points="5"]').click();
await expect(adminPage.locator('[data-test-id="story-estimate"]')).toHaveText(
  "5",
);
await expect(memberPage.locator('[data-test-id="story-estimate"]')).toHaveText(
  "5",
);
await expect(
  adminPage.locator('[phx-click="set-story-active"]').first(),
).toContainText("Done");

If we use page objects, the same test code can look like the following, which is more readable and DRY and the selectors only need to be updated in one place.

await adminSessionPage.pickEstimateButton("5").click();
await expect(adminSessionPage.storyEstimate(0)).toHaveText("5");
await expect(memberSessionPage.storyEstimate(0)).toHaveText("5");
await expect(adminSessionPage.estimateButton).toContainText("Done");

We can also combine several element interactions into one action. In the following example, we first have to click on the Add new Story button, then enter a story name and an optional description, and the click on Save or hit enter.

await adminSessionPage.addStory(id, description);

A Test Example

Playwright has a useful feature for organising tests, test.step, which is great for end-to-end tests and makes it easy to write tests from a stakeholder point of view (similar to BDD tools). An example test written using test steps is shown below:

test("Estimating a single story in a planning session", async () => {
  await test.step("The participants can open the session page", async () => {
    await adminSessionPage.goto(sessionUrl);
    await memberSessionPage.goto(sessionUrl);
  });

  await test.step("The page displays the stories for estimation", async () => {
    await expect(adminSessionPage.storyNames).toHaveText(storyIds);
    await expect(memberSessionPage.storyNames).toHaveText(storyIds);
  });

  await test.step("The admin can select a story for estimation", async () => {
    await expect(
      memberSessionPage.setStoryToEstimateButton(0),
    ).not.toBeVisible();
    await adminSessionPage.setStoryToEstimateButton(0).click();
    await expect(memberSessionPage.setStoryToEstimateButton(0)).toBeVisible();
  });

  await test.step("The active story is displayed for participants", async () => {
    await expect(adminSessionPage.activeStoryName).toHaveText(stories[0].id);
    await expect(memberSessionPage.activeStoryName).toHaveText(stories[0].id);
  });

  await test.step("The estimate button is set to Estimating", async () => {
    await expect(adminSessionPage.estimateButton(0)).toContainText(
      "Estimating",
    );
  });

  await test.step("Users can estimate the story", async () => {
    await adminSessionPage.pointEstimateButton(5).click();
    await memberSessionPage.pointEstimateButton(8).click();
  });

  // more test steps...
});

When running the test, Playwright generates the following type of report:

Playwright Report

In the screenshot, each step is a user’s action in a planning session in this particular flow. It does not show any technical details but concentrates on how the application works from the business point of view. This is a very small application, and the benefits of structuring tests this way become more apparent the more complicated the project is and the more stakeholders there are. It makes it easy to communicate the expected behaviour from the product owner to the developer.

Note: Eagle-eyed readers might notice the 1.8 seconds it takes to run the before hook. This is due to the tests currently creating a session and setting up the stories via the UI. This will be improved by adding an API to set up the test data in the development environment.

Setup

Setting up Playwright tests in a Phoenix project is straightforward and can be done following the Getting Started guide on the Playwright website. I want to keep the tests separate from the application code so I created an e2e-tests directory in the repo. This application itself has no npm dependencies that need to be added to a package.json file, although some will be installed together with LiveView.

Once Playwright is installed in the project, you can start writing tests using TypeScript or JavaScript.

We need to configure the base URL of the application so that Playwright knows where to connect the browser sessions. The server should also to start when running the tests so that we don't need to do it separately. The URL to access the server can be defined by setting the baseURL in the playwright.config.ts file. It is hardcoded in this example, but it should be read from an environment variable.

export default defineConfig({
  use: {
    baseURL: "http://localhost:4002",
  },
});

Getting the Phoenix server started on the specified port for each test run can be done by configuring the webServer section in the Playwright configuration file.

  webServer: {
    command: "cd .. && MIX_ENV=test mix phx.server",
    url: "http://localhost:4002",
    reuseExistingServer: !process.env.CI,
    timeout: 5000,
  }

Elixir projects use mix commands for running tasks, and that’s also what we want for the end-to-end tests. We can add an alias in the project mix.exs that allows us to run the tests from the project level. Additionally, Playwright has a neat HTML report, and adding another alias to get to the report saves some time.

defp aliases do
  [
    # other aliases...
    "test.e2e": ["cmd cd e2e-tests && yarn install && yarn playwright test"],
    "test.e2e.report": ["cmd cd e2e-tests && yarn install && yarn playwright show-report"
  ]
end

Setting up Test Data

The tests are quite quick: It takes about 30 seconds to run the end-to-end tests in 6 different browser configurations. The report shows that most of that time goes to setting up the data via the UI. It’s always a smart idea to set up the data using an API or another way to avoid the overhead of the UI.

In a Phoenix application, creating a new API endpoint is quite straightforward. The endpoint should only be available in development or test environments. Adding an endpoint is documented in Phoenix documentation, and you can use the already existing context functions to set up the data based on the payload of the API call.

Manual Testing

While it’s critical to have good automated test coverage, we should not forget about manual testing. The person writing automated tests will have to some amount of manual testing to see how to application works. Manual testing is a process where the person doing should think about all types of potential problems, even outside of the requirements, and whether the requirements even make sense. With this application, there have been several problem that would be very tricky to catch with automation.

After testing the application manually by ourselves, there will definitely be problems that will occur for the users using the system. These will need to be fixed as they appear and automation test coverage is a tool that allows to make these changes and fixes efficiently.

Where to Find More Information

I can recommend the Designing Elixir Systems with OTP book from Bruce Tate and James Edward Gray. Also, Pragmatic Studio has a great course about Phoenix LiveView explaining building LiveView applications in detail.

Conclusion

This was a long post and congratulations if you got to the end. I hope you found some useful information in this post. Feel free to contact me on LinkedIn or on social media if you have any questions,comments or improvement suggestions. I'm always happy to discuss test automation, Elixir and Phoenix, and help you with your projects to get the right amount of test coverage.