In this installment of our test driven forum building exercise, we will add the ability for a user to browse threads based on a given channel. We’ll start by adding a test for browsing distinct threads. Along the way we are going to run into many errors which will guide us on what steps to take next. During the process of creating our first test, we’ll see the need to create a more specific unit test in order to get the feature test to pass. We’ll change our route model binding setup to make use of a slug rather than an id, and we’ll also build out a new method on the Channel model. Ready, set, go!
Adding a Test For Browsing Distinct Threads
Now that we have added support for channels into our forum, it would make sense that we can actually browse according to channel. If we are browsing a given URI which belongs to a given channel, we should see only threads associated with that channel. Let’s start cooking up a test for that. A first stab at this may look like so:
<?php
namespace TestsFeature;
use TestsTestCase;
use IlluminateFoundationTestingRefreshDatabase;
use IlluminateFoundationTestingDatabaseMigrations;
class ReadThreadsTest extends TestCase
{
use DatabaseMigrations;
public function setUp()
{
parent::setUp();
$this->thread = create('AppThread');
}
public function test_a_user_can_browse_threads()
{
$response = $this->get('/threads');
$response->assertSee($this->thread->title);
$response->assertStatus(200);
}
public function test_a_user_can_read_a_single_thread()
{
$response = $this->get('/threads/' . $this->thread->channel . '/' . $this->thread->id);
$response->assertSee($this->thread->title);
$response->assertStatus(200);
}
public function test_a_user_can_see_replies_that_are_associated_with_a_thread()
{
$reply = factory('AppReply')->create(['thread_id' => $this->thread->id]);
$response = $this->get('/threads/' . $this->thread->id . '/' . $this->thread->id);
$response->assertSee($reply->body);
$response->assertStatus(200);
}
public function test_a_user_can_filter_threads_according_to_channel()
{
$channel = create('AppChannel');
$threadInChannel = create('AppChannel', ['channel_id' => $channel->id]);
$threadNotInChannel = create('AppChannel');
$this->get('/threads/' . $channel->slug)
->assertSee($threadInChannel->title)
->assertDontSee($threadNotInChannel->title);
}
}
Ok let’s test it out.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 733 ms, Memory: 8.00MB There was 1 error: 1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel IlluminateDatabaseQueryException: SQLSTATE[HY000]: General error: 1 table channels has no column named channel_id (SQL: insert into "channels" ("name", "slug", "channel_id", "updated_at", "created_at") values (cupiditate, cupiditate, 2, 2018-01-04 19:06:33, 2018-01-04 19:06:33))
Hmm, wait. That doesn’t seem right. Oh ok, it looks like we messed up the test code. First we need to create a channel and then two threads. Our first stab had a mistake where we created 3 channels. Let’s fix that up.
public function test_a_user_can_filter_threads_according_to_channel()
{
$channel = create('AppChannel');
$threadInChannel = create('AppThread', ['channel_id' => $channel->id]);
$threadNotInChannel = create('AppThread');
$this->get('/threads/' . $channel->slug)
->assertSee($threadInChannel->title)
->assertDontSee($threadNotInChannel->title);
}
Are we good now? Let’s see.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel PHPUnit 6.5.5 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 778 ms, Memory: 8.00MB There was 1 failure: 1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel Failed asserting that 'the html' contains "Quibusdam et sapiente impedit rerum unde eligendi.".
Well, we get an error, but not really the error we were expecting. To be clear, we are expecting a route not found error. Maybe this is one of those cases where we need to toggle exception handling. Let’s try that.
public function test_a_user_can_filter_threads_according_to_channel()
{
$channel = create('AppChannel');
$threadInChannel = create('AppThread', ['channel_id' => $channel->id]);
$threadNotInChannel = create('AppThread');
$this->withoutExceptionHandling()->get('/threads/' . $channel->slug)
->assertSee($threadInChannel->title)
->assertDontSee($threadNotInChannel->title);
}
This is the error we were looking for.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 720 ms, Memory: 8.00MB There was 1 error: 1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel SymfonyComponentHttpKernelExceptionNotFoundHttpException: GET http://localhost/threads/sed
With that in place, how do we fix this type of error? We add the needed route to the routes file of course. Here we go:
<?php
Route::get('/', function () {
return view('welcome');
});
Auth::routes();
Route::get('/home', 'HomeController@index')->name('home');
Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::post('/threads', 'ThreadsController@store');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
We know before we even run the test that we are going to need to update the index() method on the ThreadsController class in order to handle this new route so let’s focus on that now.
Two For One
What we are setting up here is the ability for the index() method on the ThreadsController class to handle two scenarios for us. On the one hand, we know that typically an index() method is used to give us all of a particular resource. We can also use this method with an argument so that when it is provided, only a specific type of resource is returned. So what we want to have happen is, if no channel slug is provided, then we get all threads. If a channel slug is provided, than we only want the threads associated with that channel slug. Here is how we can make that work.
public function index($channelSlug = null)
{
if ($channelSlug) {
$channelId = Channel::where('slug', $channelSlug)->first()->id;
$threads = Thread::where('channel_id', $channelId)->latest()->get();
} else {
$threads = Thread::latest()->get();
}
return view('threads.index', compact('threads'));
}
Now we can run our test and see that it is working, great!
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel PHPUnit 6.5.5 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 1.32 seconds, Memory: 10.00MB OK (1 test, 2 assertions)
Refactor To Use Route Model Binding
Let’s refactor how we want the code to read, even if the associated methods are not present yet. For example, if we refactor our index method like so:
public function index(Channel $channel)
{
if ($channel->exists) {
$threads = $channel->threads()->latest()->get();
} else {
$threads = Thread::latest()->get();
}
return view('threads.index', compact('threads'));
}
We know that the threads() method does not yet exist, but we like how the code reads. So we will leave this in place, and build out what we need to in order to make it work. Let’s start by running the test and see where we are at.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 971 ms, Memory: 8.00MB There was 1 error: 1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel IlluminateDatabaseEloquentModelNotFoundException: No query results for model [AppChannel].
Looks like we have a model not found exception. Interesting. Why is this? Ok what is happening here is that Route Model Binding in Laravel is always going to fetch the model by it’s id in a default configuration. We are passing a slug to find the model in this case, so now what? Well, you can specify in the Model class to use a slug instead of an id like this:
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
class Channel extends Model
{
public function getRouteKeyName()
{
return 'slug';
}
}
Cool! Let’s run the test again.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 904 ms, Memory: 8.00MB There was 1 error: 1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel BadMethodCallException: Call to undefined method IlluminateDatabaseQueryBuilder::threads()
Ok, not bad. We fixed the other error but now we have an undefined method error. We kind of expected this however since we know that the threads() method is not defined on the model yet. We can build it out now, and include another test! This will be a Unit Test for the Channel.
<?php
namespace TestsFeature;
use IlluminateFoundationTestingDatabaseMigrations;
use TestsTestCase;
use IlluminateFoundationTestingWithFaker;
use IlluminateFoundationTestingRefreshDatabase;
class ChannelTest extends TestCase
{
use DatabaseMigrations;
public function test_a_channel_consists_of_threads()
{
$channel = create('AppChannel');
$thread = create('AppThread', ['channel_id' => $channel->id]);
$this->assertTrue($channel->threads->contains($thread));
}
}
Here we create a channel, and then a thread in that channel. We then assert that the collection of threads in that channel has the thread. Run the test and we get an expected failure since we need a method.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_channel_consists_of_threads PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 840 ms, Memory: 8.00MB There was 1 error: 1) TestsFeatureChannelTest::test_a_channel_consists_of_threads Error: Call to a member function contains() on null /home/vagrant/Code/forumio/tests/Unit/ChannelTest.php:19 ERRORS! Tests: 1, Assertions: 0, Errors: 1.
We can now go ahead and build out that method on our model like so:
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
class Channel extends Model
{
public function getRouteKeyName()
{
return 'slug';
}
public function threads()
{
return $this->hasMany(Thread::class);
}
}
We can run the unit test again and it should now pass!
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_channel_consists_of_threads PHPUnit 6.5.5 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 687 ms, Memory: 8.00MB OK (1 test, 1 assertion)
Fantastic. We should now be able to go back and run our feature test and we should be good there as well!
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel PHPUnit 6.5.5 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 1.05 seconds, Memory: 10.00MB OK (1 test, 2 assertions)
Running All Tests
We’re at a pretty good spot now. We added a feature, added some tests to support it, made some refactors, and those tests are passing nicely. So we are all done and good to go… Or are we? It’s a good idea to run the full suite of tests any time you add some new features to the codebase. We need to make sure that we didn’t affect anything elsewhere. Ok, let’s go ahead and do that.
vagrant@homestead:~/Code/forumio$ phpunit PHPUnit 6.5.5 by Sebastian Bergmann and contributors. F................... 20 / 20 (100%) Time: 2.47 seconds, Memory: 12.00MB There was 1 failure: 1) TestsFeatureCreateThreadsTest::test_guest_can_not_create_threads Response status code [404] is not a redirect status code. Failed asserting that false is true. /home/vagrant/Code/forumio/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:93 /home/vagrant/Code/forumio/tests/Feature/CreateThreadsTest.php:16 FAILURES! Tests: 20, Assertions: 32, Failures: 1.
Uh oh. We have a problem. One of our tests is now failing. We are going to need to fix this before moving forward. It looks like the test of test_guest_can_not_create_threads() is failing, so let’s inspect that test.
function test_guest_can_not_create_threads()
{
$this->get('/threads/create')->assertRedirect('/login');
$this->post('/threads')->assertRedirect('login');
}
We move the route we had created further down in the routes file and this should clear things up.
<?php
Route::get('/', function () {
return view('welcome');
});
Auth::routes();
Route::get('/home', 'HomeController@index')->name('home');
Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::post('/threads', 'ThreadsController@store');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
Running the full suite of tests shows that now, everything is passing. Great!
vagrant@homestead:~/Code/forumio$ phpunit PHPUnit 6.5.5 by Sebastian Bergmann and contributors. .................... 20 / 20 (100%) Time: 2.34 seconds, Memory: 12.00MB OK (20 tests, 35 assertions)
Displaying All Threads In A Dropdown Menu
We can make a few edits to our app.blade.php layout file so that we have a link to all threads, as well as a dropdown for all channels that exist. Then, the user can click on each individual channel in the dropdown menu and only threads associated with that channel will display. Check out the highlighted markup below that will set this up for you.
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Styles -->
<link href="{{ asset('css/app.css') }}" rel="stylesheet">
</head>
<body style="padding-bottom: 100px">
<div id="app">
<nav class="navbar navbar-default navbar-static-top">
<div class="container">
<div class="navbar-header">
<!-- Collapsed Hamburger -->
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#app-navbar-collapse" aria-expanded="false">
<span class="sr-only">Toggle Navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<!-- Branding Image -->
<a class="navbar-brand" href="{{ url('/threads') }}">
{{ config('app.name', 'Laravel') }}
</a>
</div>
<div class="collapse navbar-collapse" id="app-navbar-collapse">
<!-- Left Side Of Navbar -->
<ul class="nav navbar-nav">
<li><a href="/threads">All Threads</a></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Channels
<span class="caret"></span></a>
<ul class="dropdown-menu">
@foreach(AppChannel::all() as $channel)
<li><a href="/threads/{{$channel->slug}}">{{$channel->name}}</a></li>
@endforeach
</ul>
</li>
</ul>
<!-- Right Side Of Navbar -->
<ul class="nav navbar-nav navbar-right">
<!-- Authentication Links -->
@guest
<li><a href="{{ route('login') }}">Login</a></li>
<li><a href="{{ route('register') }}">Register</a></li>
@else
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false" aria-haspopup="true">
{{ Auth::user()->name }} <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="{{ route('logout') }}"
onclick="event.preventDefault();
document.getElementById('logout-form').submit();">
Logout
</a>
<form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
{{ csrf_field() }}
</form>
</li>
</ul>
</li>
@endguest
</ul>
</div>
</div>
</nav>
@yield('content')
</div>
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>
Sure enough, we now have a nice dropdown with all of the test channels that have been set up in our project so far. Nice!
How To Filter Models By Another Model
In this tutorial we successfully set up the ability to filter threads by channels. The nice thing is that it is all backed up by tests as well. If you know how to do this with one type of model, you can easily apply it to other scenarios. Here we we used channels to filter threads. The same approach would work when you filter blog posts by categories, or threads by tags for example. Nice work!