Basic Feature Testing in Laravel – A Guide From Someone Still Figuring This Out

2 years ago May 15, 2022 - 11:05 am

Automated testing is a tool that I wish I discovered when I was learning software development. So far, it has helped me notice bugs that I may have missed if I did manual testing. It erases some unknowns and worries (it won’t erase them 100%) when shipping the application to production.

Laravel has PHPUnit integration by default. You can run all of the tests by entering “php artisan test”. In order to run a specific test, you can add “--filter=YourTestName”.

There is a certain lack of guides when dealing with basic CRUD applications. So this guide was written to address that. But first, I want to explain how to get into the mindset of writing automated tests.

Writing automated tests requires you to ask three questions.

1. What does the feature do?

2. What do I expect the feature would output?

3. What happens if the feature functions incorrectly or outputs something incorrect?

Writing tests helps you answer these three questions, without the need to refresh the page and fill in text field after text field to verify. A nice time saver and you might do your sanity a favor.

Writing the Tests

Note: As of this writing, I wrote the test code and ran it using PHPUnit 9.5.10 and Laravel 8.75. It might vary depending on newer versions.

So in this example we have a model called Entry and a controller called EntryController with the default functions such as index, store, show, edit, update and destroy. Each entry is tied to a Project.

Now this isn't the de facto way to write tests for every situation. You might want to tweak the following code according to your needs.

For some context, here's the model the tests are based on.

namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Entry extends Model
{
    protected $table = 'entries';
    protected $primaryKey = 'id';
    protected $guarded = ['id'];
    public $timestamps = true;

    public function setContentAttribute($value){
        $this->attributes['content'] = $value;
    }
    public function setTagsAttribute($value){
        $this->attributes['tags'] = json_encode($value);
    }
    public function getTagsAttribute(){
        return json_decode($this->attributes['tags']);
    }
}

Here is the code for the EntryController for context.

namespace App\Http\Controllers;

use App\Models\Entry;
use App\Models\Project;
use App\Models\Template;
use App\Http\Requests\EntryRequest;

use Sinnbeck\Markdom\Facades\Markdom;

class EntryController extends Controller
{
    /**
     * Show the form for creating a new resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function create($projectId)
    {
        $project = Project::find($projectId);
        $templates = Template::where('project_id', $project->id)->orWhereNull('project_id')->get();
        if (isset($project)){
            return view('entries.create', compact('project', 'templates'));
        } else {
            return redirect()->back();
        }
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(EntryRequest $request)
    {
        $newEntry = Entry::create($request->validated());
        return redirect()->route('entries.show', $newEntry->id);
    }

    /**
     * Display the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function show($id)
    {
        $selectedEntry = Entry::find($id);
        if (isset($selectedEntry)){
            $selectedEntry->content = Markdom::toHtml($selectedEntry->content);
            return view('entries.view', compact('selectedEntry'));
        } else {
            return redirect()->route('projects.index');
        }
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function edit($id)
    {
        $entry = Entry::find($id);
        $templates = Template::where('project_id', $entry->project_id)
->orWhereNull('project_id')->get();
        if (isset($entry)){
            return view('entries.edit', compact('entry', 'templates'));
        } else {
            return redirect()->back();
        }
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function update(EntryRequest $request, $id)
    {
        $selectedEntry = Entry::find($id);
        if (isset($selectedEntry)){
            $selectedEntry->update($request->all());
            return redirect()->route('entries.show', $selectedEntry->id);
        } else {
            return redirect()->back();
        }
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function destroy($id)
    {
        $selectedEntry = Entry::find($id);
        $entryProjectId = $selectedEntry->project_id;

        if (isset($selectedEntry)){
            $selectedEntry->delete();
        }

        return redirect()->route('projects.show', $entryProjectId);
    }
}

Testing if index() is accessible

Let’s start with something simple. Checking if the page listing all of the entries saved in our web application is accessible.

    public function test_entry_list_page_can_be_accessed()
    {
        $response = $this->get(route(‘entries.index’));
        $response->assertStatus(\Illuminate\Http\Response::HTTP_OK);
    }

Testing if the controller can submit a form and save the data

    public function test_saves_a_new_entry()
    {
        $testProject = Project::factory()->create(); //Creates a project, you need to create working Factory class before being able to do this.
        $response = $this->post(route('entries.store'), [
            'project_id' => $testProject->id,
            'title' => 'Test!',
            'content' => 'Test content!',
        ]);
        $response->assertStatus(\Illuminate\Http\Response::HTTP_FOUND);
        $response->assertSessionDoesntHaveErrors();
    }

In this piece of code, we are creating a new Project, then submitting a new entry under that Project in which we expect to get a status code of 302, which indicates that the submission was a success and we have been redirected to the page showing the contents of the new entry.

Each function can also have more than one assertion. The test will fail if any of them do not pass.

Testing if the store() function would not accept a submission with missing input.

public function test_does_not_save_when_required_field_is_missing()
{
        $user = User::create([
            'first_name'          => 'Web',
            'middle_name'         => '',
            'last_name'           => 'Administrator',
            'email'               => $this->faker->safeEmail,
            'username'            => $this->faker->username,
            'password'            => bcrypt('secret'),
            'status'              => 1,
            'email_verified_at'   => Carbon::now()
        ]);

        $this->actingAs($user);

        $response = $this->post(route('form-management.store'), [
            'name'        => "",
            'description' => "Test field is missing!",
        ]);

        $response->assertSessionHasErrors();
}

"$response->assertSessionHasErrors();" asserts, as the name implies, that the submission should output an error.

Testing if the application can update an existing entry

    public function test_can_update_an_entry()
    {
        $testProject = Project::factory()->create();

        $testEntry = Entry::create([
            'project_id' => $testProject->id,
            'title' => 'Test!',
            'content' => 'Test content!',
        ]);

        $response = $this->patch(route('entries.update', $testEntry->id), [
            'project_id' => $testProject->id,
            'title' => 'Updated title!',
            'content' => 'Updated content!',
        ]);

        $response->assertRedirect(route('entries.show', $testEntry->id));
    }

In this function, we create a Project and a new Entry that we can update. We then use "$this->patch()" to create a PUT request that simulates a user updating an entry. Because we have the ID of the entry being updated, we can assert and check if the function redirects to the show() function route as expected.

Testing if the application can delete an entry

    public function test_can_delete_an_entry()
    {
        $testProject = Project::factory()->create();

        $testEntry = Entry::create([
            'project_id' => $testProject->id,
            'title' => 'Test!',
            'content' => 'Test content!',
        ]);

        $response = $this->delete(route('entries.destroy', $testEntry->id));
       
        $testQuery = Entry::find($testEntry->id);
        $this->assertNull($testQuery);
    }

Here, just like the other tests, we create a Project and Entry and we delete it through the "$this->delete()" function call and we assert that when we query for the entry (which is where "Entry::find($testEntry->id);" comes into play), it will return a null instead of the Entry object with the ID, proving that the destroy function works as intended.

And that is it for now. I hope this little guide helped you in getting to know the basics of using PHPUnit for feature tests. Next, I might cover how to handle testing Traits in Laravel.