2

I'm using Laravel for my project and I'm new to unit/feature testing so I was wondering what is the best way to approach more complicated feature cases when writing tests?

Let's take this test example:

  // tests/Feature/UserConnectionsTest.php
  public function testSucceedIfConnectAuthorised()
  {
    $connection = factory(Connection::class)->make([
      'sender_id'     => 1,
      'receiver_id'   => 2,
      'accepted'      => false,
      'connection_id' => 5,
    ]);

    $user = factory(User::class)->make([
      'id' => 1,
    ]);

    $response = $this->actingAs($user)->post(
      '/app/connection-request/accept',
      [
        'accept'     => true,
        'request_id' => $connection->id,
      ]
    );

    $response->assertLocation('/')->assertStatus(200);
  }

So we've got this situation where we have some connection system between two users. There is a Connection entry in the DB created by one of the users. Now to make it a successful connection the second user has to approve it. The problem is within the UserController accepting this through connectionRequest:

  // app/Http/Controllers/Frontend/UserController.php
  public function connectionRequest(Request $request)
  {
    // we check if the user isn't trying to accept the connection
    // that he initiated himself
    $connection = $this->repository->GetConnectionById($request->get('request_id'));
    $receiver_id = $connection->receiver_id;
    $current_user_id = auth()->user()->id;

    if ($receiver_id !== $current_user_id) {
      abort(403);
    }

    [...]
  }


  // app/Http/Repositories/Frontend/UserRepository.php 
  public function GetConnectionById($id)
  {
    return Connection::where('id', $id)->first();
  }

So we've got this fake (factory created) connection in the test function and then we unfortunately are using its fake id to run a check within the real DB among real connections, which is not what we want :(

Researching I found an idea of creating interfaces so then we can provide a different method bodies depending if we're testing or not. Like here for GetConnectionById() making it easy to fake answers to for the testing case. And that seems OK, but:

  • for one it looks like a kind of overhead, besides writing tests I have to make the "real" code more complicated itself for the sole purpose of testing.
  • and second thing, I read all that Laravel documentation has to say about testing, and there is no one place where they mention using of interfaces, so that makes me wonder too if that's the only way and the best way of solving this problem.
Picard
  • 3,745
  • 3
  • 41
  • 50
  • Where is the code where you run it against the real db? you can mock that but from your question i can't really figure out where that is. – mrhn Sep 12 '21 at 11:00
  • It's the last code sample - `UserRepository`. It is the real code that is used by the application and that's my it works on the real DB. The first code sample - `UserConnectionsTest.php` is where factory-make the fake connection that isn't in the real DB. – Picard Sep 12 '21 at 12:22
  • I would be inclined to suggest you mock the repository and set expectations on the functions to retrieve and approve connections (if any) that way you don't need DB changes. Alternatively you can either use a test db or the `DatabaseTransactions` trait to rollback any db changes – apokryfos Sep 12 '21 at 14:20
  • In this case you use make() make does not add anything to the database, why is that? – mrhn Sep 12 '21 at 14:33
  • @mrhn wellI don't want real entries in the DB because after the tests I would have to remove them from my development DB. I just need some class instances to work with. I thought about using SQLite stored in memory for the tests, but then I'd have to do a lot of initial DB setup to have the complete application context for all the functionalities to work. Well but maybe it should be done this way? As I said I'm new to testing and I'm expecting to find a way that would allow me to write tests possibly without adding much superfluous code within the app itself. – Picard Sep 12 '21 at 16:46
  • @apokryfos `DatabaseTransactions` great, I didn't thought about this! I have to give it a try. – Picard Sep 12 '21 at 16:58
  • 1
    When testing you should not at all test against a real database, either test against a test db in your environment and many prefer to use Sqlite with the db set to in memory for testing. – mrhn Sep 12 '21 at 17:30

1 Answers1

7

I will try to help you, when someone start with testing it is not easy at all, specially if you don't have a strong framework (or even a framework at all).

So, let me try help you:

  • It is really important to differentiate Unit testing vs Feature testing. You are correctly using Feature test, because you want to test business logic instead of a class directly.
  • When you test, my personal recommendation is always create a second DB to only use with tests. It must be completely empty all the time.
    • So, for you to achieve this, you have to define the correct environment variables in phpunit.xml, so you don't have to do magic for this to work when you only run tests.
    • Also, use RefreshDatabase trait. So, each time you run a test, it is going to delete everything, migrate your tables again and run the test.
  • You should always create what you need to have as mandatory for your test to run. For example, if you are testing if a user can cancel an order he/she created, you only need to have a product, a user and an invoice associated with the product and user. You do not need to have notifications created or anything not related to this. You must have what you expect to have in the real case scenario, but nothing extra, so you can truly test that it fully works with the minimum stuff.
  • You can run seeders if your setup is "big", so you should be using setup method.
  • Remember to NEVER mock core code, like request or controllers or anything similar. If you are mocking any of these, you are doing something wrong. (You will learn this with experience, once you truly know how to test).
  • When you write tests names, remember to never use if and must and similar wording, instead use when and should. For example, your test testSucceedIfConnectAuthorised should be named testShouldSucceedWhenConnectAuthorised.
  • This tip is super personal: do not use RepositoryPattern in Laravel, it is an anti-pattern. It is not the worst thing to use, but I recommend having a Service class (do not confuse with a Service Provider, the class I mean is a normal class, it is still called Service) to achieve what you want. But still, you can google about this and Laravel and you will see everyone discourages this pattern in Laravel.
  • One last tip, Connection::where('id', $id)->first() is exactly the same as Connection::find($id).
  • I forgot to add that, you should always hardcode your URLs (like you did in your test) because if you rely on route('url.name') and the name matches but the real URL is /api/asdasdasd, you will never test that the URL is the one you want. So congrats there ! A lot of people do not do this and that is wrong.

So, to help you in your case, I will assume you have a clear database (database without tables, RefreshDatabase trait will handle this for you).

I would have your first test as this:

public function testShouldSucceedWhenConnectAuthorised()
{
    /**
     * I have no idea how your relations are, but I hope
     * you get the main idea with this. Just create what
     * you should expect to have when you have this
     * test case
     */
    $connection = factory(Connection::class)->create([
        'sender_id' => factory(Sender::class)->create()->id,
        'receiver_id' => factory(Reciever::class)->create()->id,
        'accepted' => false,
        'connection_id' => factory(Connection::class)->create()->id,
    ]);

    $response = $this->actingAs(factory(User::class)->create())
        ->post(
            '/app/connection-request/accept',
            [
                'accept' => true,
                'request_id' => $connection->id
            ]
        );

    $response->assertLocation('/')
        ->assertOk();
}

Then, you should not change anything except phpunit.xml environment variables pointing to your testing DB (locally) and it should work without you changing anything in your code.

matiaslauriti
  • 7,065
  • 4
  • 31
  • 43
  • Thanks this is really helpful, you also helped me to confirm some of my own findings and ideas of how it should work. I didn’t like the idea of mocking up some the methods for the tests to work since then it wouldn’t run on the real code. Now I also know how to approach the database part of the tests and thanks for the tip about the repository pattern I will read more about it also. – Picard Sep 12 '21 at 21:00
  • 1
    Sure ! Glad to help ! I have 3 - 4 years experience testing in Laravel, so I am really into it. I cannot share a page where you have a lot of examples as I do not know any of this, but I would recommend you try searching for more examples that follows my guidelines ! Once you understand how to test this, you will be super fast and will also be able to do TDD, so you should have 100% of your feature code tested ! – matiaslauriti Sep 12 '21 at 21:02