How do you refactor your tests and controllers?

I have a nested resource controller as well as a test for this controller. I’m used to using Rspec in Rails and find myself at a loss on how to organize my test cases. I feel like I’m repeating myself a lot and I wonder if anyone with more experience has any insight on how I could better write this:

My test:

defmodule LolipollWeb.QuestionControllerTest do
  use LolipollWeb.ConnCase

  alias Lolipoll.Repo
  alias Lolipoll.Polls.Poll
  alias Lolipoll.Polls.Question

  @create_attrs %{title: "Question title"}
  @invalid_attrs %{title: nil}

  describe "index" do
    test "lists all questions", %{conn: conn} do
      poll = create_poll()
      question = poll.questions |> List.first

      conn = get(conn, Routes.poll_question_path(conn, :index, poll.id))
      assert html_response(conn, 200) =~ poll.name
      assert html_response(conn, 200) =~ question.title
    end
  end

  describe "new question" do
    test "renders form", %{conn: conn} do
      poll = Repo.insert!(%Poll{ name: "Sample Poll" })

      conn = get(conn, Routes.poll_question_path(conn, :new, poll.id))
      assert html_response(conn, 200) =~ "New Question"
    end
  end

  describe "create question" do
    test "redirects to show when data is valid", %{conn: conn} do
      poll = create_poll()

      conn = post(conn, Routes.poll_question_path(conn, :create, poll.id), question: @create_attrs)

      assert %{id: id} = redirected_params(conn)
      assert redirected_to(conn) == Routes.poll_question_path(conn, :show, poll.id, id)

      conn = get(conn, Routes.poll_question_path(conn, :show, poll.id, id))
      assert html_response(conn, 200) =~ "Show Question"
    end

    test "renders errors when data is invalid", %{conn: conn} do
      poll = create_poll()

      conn = post(conn, Routes.poll_question_path(conn, :create, poll.id), question: @invalid_attrs)
      assert html_response(conn, 200) =~ "New Question"
    end
  end

  describe "delete question" do
    test "deletes chosen question", %{conn: conn} do
      poll = create_poll()

      question = poll.questions |> List.first
      conn = delete(conn, Routes.poll_question_path(conn, :delete, poll.id, question))
      assert redirected_to(conn) == Routes.poll_question_path(conn, :index, poll.id)
      assert_error_sent 404, fn ->
        get(conn, Routes.poll_question_path(conn, :show, poll.id, question))
      end
    end
  end

  defp create_poll do
    Repo.insert!(%Poll{
      name: "Sample Poll",
      questions: [
        %Question{title: "Bacon?"}
      ]
    })
  end
end

My controller:

defmodule LolipollWeb.QuestionController do
  use LolipollWeb, :controller

  alias Lolipoll.Polls
  alias Lolipoll.Polls.Question

  def index(conn, %{"poll_id" => poll_id}) do
    poll = Polls.get_poll!(poll_id)
    questions = Polls.list_poll_questions(poll.id)

    render(conn, "index.html", questions: questions, poll: poll)
  end

  def new(conn, %{"poll_id" => poll_id}) do
    poll = Polls.get_poll!(poll_id)
    changeset = Polls.change_question(%Question{})
    render(conn, "new.html", changeset: changeset, poll: poll)
  end

  def create(conn, %{"poll_id" => poll_id, "question" => question_params}) do
    poll = Polls.get_poll!(poll_id)
    question_params = Map.merge(question_params, %{"poll_id" => poll_id})

    case Polls.create_question(question_params) do
      {:ok, question} ->
        conn
        |> put_flash(:info, "Question created successfully.")
        |> redirect(to: Routes.poll_question_path(conn, :show, poll_id, question))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset, poll: poll)
    end
  end

  def show(conn, %{"poll_id" => poll_id, "id" => id}) do
    poll = Polls.get_poll!(poll_id)
    question = Polls.get_question!(id)
    render(conn, "show.html", poll: poll, question: question)
  end

  def delete(conn, %{"poll_id" => poll_id, "id" => id}) do
    question = Polls.get_question!(id)
    {:ok, _question} = Polls.delete_question(question)

    conn
    |> put_flash(:info, "Question deleted successfully.")
    |> redirect(to: Routes.poll_question_path(conn, :index, poll_id))
  end
end

Is there a way to memoize the create_poll() method so I could just call poll like I would in Rails? I really appreciate any help here and if I’m posting this in the wrong place do let me know.

1 Like

IIRC you can use ExUnit’s Callbacks
This will be executed before each test in your case.
Example:

setup_all context do
  [poll: create_poll()]
end

It will be passed to the context argument, merging with the already provided by other callbacks, so you would have on your tests:

describe "delete question" do
    test "deletes chosen question", %{conn: conn, poll: poll} do
      question = poll.questions |> List.first
      conn = delete(conn, Routes.poll_question_path(conn, :delete, poll.id, question))
      assert redirected_to(conn) == Routes.poll_question_path(conn, :index, poll.id)
      assert_error_sent 404, fn ->
        get(conn, Routes.poll_question_path(conn, :show, poll.id, question))
      end
    end
  end

See the same way you already use %{conn: conn} to use the connection

1 Like

Thank you so much for that! This really helps clean it up! I wonder if there are any books or articles that I could read on this. My google fu came up short.

1 Like

This is my favorite article on the subject: https://9elements.com/io/maintainable-test-setup-with-scenario-pipelines/

Not that I always follow the guidelines in the article, but it is definitely food for thought. It speaks to some of the downsides of relying on the setup blocks to create shared data (you can’t customize it for a specific test)

5 Likes

Thanks for the resource. This is bang on what I needed to learn more!

1 Like

Great! I’m glad it helps!