Stefan Bauer
Developer and Creator of PingPing.io, a simple website monitoring.
Blog Building PingPing About
"Building PingPing" is an open-source project. You can find the sources on GitHub

#005: Outlining the first tests

Published on March 10, 2018

Some thoughts first

First of all, I wanted to apologize that it took me so long to create this episode. I've been really busy the past few days. I also gave some thought on what should be the format for the new episodes. I found that it's very difficult to show actual progress in a written blog post. Therefore I'm not going to explain every single line of code and its details in the future. I'll pick out and explain the most relevant aspects of each episode which will also have a corresponding commit. I hope you like this new format.

Preparing Feature and Unit Tests

By default, Laravel provides a TestCase class which can be extended for Feature and Unit tests. Maybe you don't know it, but there is also a withExceptionHandling() and withoutExceptionHandling() method on that class. Often you will need to disable or enable Laravel's Exception Handling in order to get a more detailed description about an error in a test. I thought about that and came to the conclusion, that most of the time, you need exception handling in Feature tests, but don't need it in Unit tests. Because of that, I'll try a new approach here. Never tried it before, but we'll see how it works. If it doesn't work, we will refactor it.

So what I'm gonna do is create two new classes on top of the TestCase. One UnitTestCase and one FeatureTestCase. In each of them we enable or disable the exception handling stuff by default. Here is the result:

--- /dev/null
+++ b/tests/UnitTestCase.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Tests;
+
+class UnitTestCase extends TestCase
+{
+    protected function setUp()
+    {
+        parent::setUp();
+
+        $this->withoutExceptionHandling();
+    }
+}
--- /dev/null
+++ b/tests/FeatureTestCase.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Tests;
+
+class FeatureTestCase extends TestCase
+{
+    protected function setUp()
+    {
+        parent::setUp();
+
+        $this->withExceptionHandling();
+    }
+}

Updating the .php_cs.dist

While writing this code I noticed that I am used to some other conventions for the php cs fixer. I decided to change the conventions a little bit. The thing which annoys me the most is the use statements order by length. Yeah, I agree, it looks beautiful. But from my point of view it's annoying. I often take a very quick look at use statements above to check if something's missing or not. If it's in alphabetical order, no problem there. But if it's ordered by length, I always have to search a little more to find where the use statement is located. Here is what we'll go with from now on:

--- a/.php_cs.dist
+++ b/.php_cs.dist
@@ -13,11 +13,10 @@ return Config::create()
     ->setRiskyAllowed(true)
     ->setRules([
         '@Symfony' => true,
-        'strict_param' => true,
         'array_syntax' => ['syntax' => 'short'],
-        'ordered_imports' => ['sortAlgorithm' => 'length'],
+        'ordered_imports' => true,
         'phpdoc_order' => true,
-        'phpdoc_annotation_without_dot' => false,
+        'self_accessor' => false,
     ])
     ->setFinder($finder)
 ;

The tests itself

What I always do is start with the tests. I just write down everything I want to start with and want to test. Most people don't know where to start. Should they start with users? Should they start with authentication? I am not sure if there is a general rule of thumb. I personally always start with most important part of the application itself. In our case these are Services. I asked my community on Twitter how they would call URLs, that are not only typical URLs like http:// or https://, but they can also be tcp:// or icmp://. They came up with the idea of Services, which was also in my mind at first but I felt very unsure about it, for now I think it's ok. We can still rename it later if we don't like it.

So, here are the tests I created. One test on the Unit level and the other tests are on the Feature level. Let's take a look at what we've got so far.

--- /dev/null
+++ b/tests/Feature/ServiceTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Service;
+use App\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\FeatureTestCase;
+
+class ServiceTest extends FeatureTestCase
+{
+    use RefreshDatabase;
+
+    /** @test */
+    public function a_user_needs_to_be_authenticated_to_see_services()
+    {
+        $this->get('/services')->assertRedirect('/login');
+    }
+
+    /** @test */
+    public function an_authenticated_user_can_see_services()
+    {
+        $this->login($user = factory(User::class)->create());
+        $service = factory(Service::class)->create([
+            'user_id' => $user->id,
+        ]);
+
+        $this->get('/services')->assertSee($service->url);
+    }
+
+    /** @test */
+    public function an_authenticated_user_can_not_see_services_he_doesnt_own()
+    {
+        $this->login();
+
+        $aDifferentService = factory(Service::class)->create();
+
+        $this->get('/services')->assertDontSee($aDifferentService->url);
+    }
+}
--- /dev/null
+++ b/tests/Unit/ServiceTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Service;
+use App\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\UnitTestCase;
+
+class ServiceTest extends UnitTestCase
+{
+    use RefreshDatabase;
+
+    /** @test */
+    public function it_has_an_owner()
+    {
+        $service = factory(Service::class)->create();
+
+        $this->assertInstanceOf(User::class, $service->user);
+    }
+
+    /** @test */
+    public function it_gets_services_by_specific_user()
+    {
+        $user = factory(User::class)->create();
+        factory(Service::class)->create([
+            'user_id' => $user->id,
+        ]);
+
+        factory(Service::class, 2)->create();
+
+        $this->assertCount(1, Service::byUser($user)->get());
+    }
+}
--- /dev/null
+++ b/tests/Unit/UserTest.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\User;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\UnitTestCase;
+
+class UserTest extends UnitTestCase
+{
+    use RefreshDatabase;
+
+    /** @test */
+    public function it_has_services()
+    {
+        $user = factory(User::class)->make();
+
+        $this->assertInstanceOf(Collection::class, $user->services);
+    }
+}

Ah, and don't forget to delete the ExampleTest files which are included by default. Alternatively do as I did and just rename them.

Working through the tests

The most important thing is that tests are very readable, if done correctly. I don't want to explain every single test and its lines of code. Just check out the test methods and I am sure you will understand what the appropriate test does.

Anyways, There are still some technical debts we have to cover after writing these tests. If you execute them, they will surely fail. So, let's get started and update what's needed to get our tests passing.

Using a login helper

If you have a basic understanding of testing in Laravel, there are two methods you can use to login a user. The first one is $this->be() and the second one is $this->actingAs(). I like to use a more generic approach and would like to type just $this->login(). If you don't pass an argument, a random user is created and will be used for the login. Otherwise you can also pass a user object.

Let's add this simple functionality in the abstract TestCase class.

--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -2,9 +2,17 @@

 namespace Tests;

+use App\User;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

 abstract class TestCase extends BaseTestCase
 {
     use CreatesApplication;
+
+    protected function login(?User $user = null)
+    {
+        $this->actingAs($user ?: factory(User::class)->create());
+
+        return $this;
+    }
 }

We need a migration

For the services, we still need a migration, which we don't have yet. Nothing much to say about it. It's really straight forward.

--- /dev/null
+++ b/database/migrations/2018_03_07_104010_create_services_table.php
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateServicesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up()
+    {
+        Schema::create('services', function (Blueprint $table) {
+            $table->increments('id');
+            $table->unsignedInteger('user_id');
+            $table->string('identifier')->unique();
+            $table->string('alias')->nullable();
+            $table->string('url');
+            $table->timestamps();
+
+            $table
+                ->foreign('user_id')
+                ->references('id')->on('users')
+                ->onDelete('cascade')
+            ;
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropForeign(['user_id']);
+        });
+
+        Schema::dropIfExists('services');
+    }
+}

What you might be interested in is the identifier column. I'm not sure if we need it. The plan is that we have a unique identifier for some kind of public URLs you might want share to other users. We'll see how it works out.

A service factory would also be nice

Now that we have a services migration, we also need a factory. After we have that, we'll create the appropriate models to get them working.

--- /dev/null
+++ b/database/factories/ServiceFactory.php
@@ -0,0 +1,14 @@
+<?php
+
+use Faker\Generator as Faker;
+
+$factory->define(App\Service::class, function (Faker $faker) {
+    return [
+        'user_id' => function () {
+            return factory(App\User::class)->create()->id;
+        },
+        'identifier' => str_random(8),
+        'alias' => $faker->word,
+        'url' => $faker->url,
+    ];
+});

Creating a generic model

Laravel provides Mass Assignment protection by default on every model. I think this is not necessary in our case. So what I do is create a new base model and set the guarded property to an empty array, so we are good to go. All my future models will extend that base model. So the question is now, where do we create that base model?

Here's a quick tip: I always check the namespace of the class I'd like to extend or create. We would like to extend the Illuminate\Database\Eloquent\Model class, so let's create a new file in app/Database/Eloquent called Model.php. And here is the result:

--- /dev/null
+++ b/app/Database/Eloquent/Model.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Database\Eloquent;
+
+use Illuminate\Database\Eloquent\Model as BaseModel;
+
+class Model extends BaseModel
+{
+    protected $guarded = [];
+}

Creating the appropriate models

The user model

Let's start with the user model. There's not much to do here, for now we will still rely on the original Eloquent Model provided by default. We'll update it later, when we find it necessary. I just removed some basic stuff for now and added the services() relation which we don't have yet. Here's what we got.

--- a/app/User.php
+++ b/app/User.php
@@ -2,28 +2,19 @@

 namespace App;

-use Illuminate\Notifications\Notifiable;
 use Illuminate\Foundation\Auth\User as Authenticatable;
+use Illuminate\Notifications\Notifiable;

 class User extends Authenticatable
 {
     use Notifiable;

-    /**
-     * The attributes that are mass assignable.
-     *
-     * @var array
-     */
-    protected $fillable = [
-        'name', 'email', 'password',
-    ];
-
-    /**
-     * The attributes that should be hidden for arrays.
-     *
-     * @var array
-     */
     protected $hidden = [
         'password', 'remember_token',
     ];
+
+    public function services()
+    {
+        return $this->hasMany(Service::class);
+    }
 }

The Service model

Now it's time to create the service model. For now, there is also only one relation. This is a belongsTo relation to the user. Additional to that, we use a query scope. I like query scopes. Instead of typing something Service::where('user_id', Auth::id()) every single time when I would like to get all services related to the given user, I like much more the approach of typing something like Service::byUser() and pass an user object. This is possible with query scopes. The only rule to create them is to prefix them with scope. The first parameter is always the Builder instance and other parameters are those you pass into it.

--- /dev/null
+++ b/app/Service.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace App;
+
+use App\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Builder;
+
+class Service extends Model
+{
+    public function user()
+    {
+        return $this->belongsTo(User::class, 'user_id');
+    }
+
+    public function scopeByUser(Builder $query, User $user)
+    {
+        return $query->where('user_id', $user->id);
+    }
+}

Let's touch the database

We forgot to do some setup for our database. There are three things we should take care of.

Updating the phpunit.xml.dist

For the test environment, we would like to use an sqlite in memory database. To set this up, let's just change our phpunit.xml.dist. Why dist? Because this should be the default behavior. If you prefer some special settings, feel free to create a custom phpunit.xml for your local development.

--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -24,6 +24,8 @@
     </filter>
     <php>
         <env name="APP_ENV" value="testing"/>
+        <env name="DB_CONNECTION" value="sqlite"/>
+        <env name="DB_DATABASE" value=":memory:"/>
         <env name="CACHE_DRIVER" value="array"/>
         <env name="SESSION_DRIVER" value="array"/>
         <env name="QUEUE_DRIVER" value="sync"/>

Updating the .env

For my local development I always use MySQL. So be aware of your .env file and modify the database, username and password fields to your needs. I modified also the .env.dist file to give people an idea what the default can look like.

--- a/.env.dist
+++ b/.env.dist
@@ -10,9 +10,9 @@ LOG_CHANNEL=stack
 DB_CONNECTION=mysql
 DB_HOST=127.0.0.1
 DB_PORT=3306
-DB_DATABASE=homestead
-DB_USERNAME=homestead
-DB_PASSWORD=secret
+DB_DATABASE=pingping
+DB_USERNAME=root
+DB_PASSWORD=

 BROADCAST_DRIVER=log
 CACHE_DRIVER=file

Updating the Seeder

We need a DatabaseSeeder for our local development. You can execute the seeding by calling php artisan db:seed on the terminal. For now there is nothing. Let's change this and create some basic data. I won't create multiple seeders for now as we don't need them. One is enough.

--- a/database/seeds/DatabaseSeeder.php
+++ b/database/seeds/DatabaseSeeder.php
@@ -6,11 +6,16 @@ class DatabaseSeeder extends Seeder
 {
     /**
      * Run the database seeds.
-     *
-     * @return void
      */
     public function run()
     {
-        // $this->call(UsersTableSeeder::class);
+        $user = factory(\App\User::class)->create([
+            'email' => 'admin@pingping.io',
+            'password' => bcrypt('secret'),
+        ]);
+
+        factory(\App\Service::class, 10)->create([
+            'user_id' => $user->id,
+        ]);
     }
 }

Move the login view

We have already created the login view in a previous post. But until now, it's just available in the / route. What we really want is to make it available on the /login route. No problem here. Just rename the welcome.blade.php file to login.blade.php. Done!

Time to modify the routes

It's time to modify the routes file. This is an easy one. What we do is create a new login route for the login view that we just moved over. Also, we need a /services route where the user can see all the services he's created.

--- a/routes/web.php
+++ b/routes/web.php
@@ -1,16 +1,7 @@
 <?php

-/*
-|--------------------------------------------------------------------------
-| Web Routes
-|--------------------------------------------------------------------------
-|
-| Here is where you can register web routes for your application. These
-| routes are loaded by the RouteServiceProvider within a group which
-| contains the "web" middleware group. Now create something great!
-|
-*/
+Route::get('/login', function () {
+    return view('login');
+})->name('login');

-Route::get('/', function () {
-    return view('welcome');
-});
+Route::get('/services', 'ServicesController@index')->name('services');

We don't have a controller yet

In our routes file we already defined the ServicesController but we didn't create it yet. So let's do that now. We can make use of our new Service::byUser() method in there.

--- /dev/null
+++ b/app/Http/Controllers/ServicesController.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Service;
+use Illuminate\Support\Facades\Auth;
+
+class ServicesController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function index()
+    {
+        $services = Service::byUser(Auth::user())->get();
+
+        return view('services.index', [
+            'services' => $services,
+        ]);
+    }
+}

Last but not least, a basic services view

We are almost done here. The only thing missing is the services view. For now it's just a dummy template to get the tests passing. I'm not sure when we'll start working on the design aspect of the application. For now, it's better to continue with the app itself instead of focusing on design stuff.

--- /dev/null
+++ b/resources/views/services/index.blade.php
@@ -0,0 +1,14 @@
+<h1>Services</h1>
+
+<table>
+    <tr>
+        <td>Alias</td>
+        <td>URL</td>
+    </tr>
+    @foreach ($services as $service)
+        <tr>
+            <td>{{ $service->alias }}</td>
+            <td>{{ $service->url }}</td>
+        </tr>
+    @endforeach
+</table>

Finally - let's run our tests

Now we are really done! Everything should be covered. Each functionality should be implemented in the code base which we're using in our tests. It's time to run our tests.

PHPUnit 7.0.2 by Sebastian Bergmann and contributors.

Tests\Feature\Service
 ✔ A user needs to be authenticated to see services
 ✔ An authenticated user can see services
 ✔ An authenticated user can not see services he doesnt own

Tests\Unit\Service
 ✔ It has an owner
 ✔ It gets services by specific user

Tests\Unit\User
 ✔ It has services
Imprint Cookie Policy Privacy Policy
Proudly hosted with Vultr