yeasir_arafat's avatar

Unit testing of an app with shared db

Hi Artisans,

I have 2 laravel apps. One (let's say app XX) serves as the main api backend. The other(lets say app YY), has an intertia front end and laravel backend. The apps talk to each other via laravel's HTTP wrapper. The apps also share one model(Author model) between them. Since XX is the main app, the authors table resides in the XX database and i am sharing the DB connection from app YY for the Author model there via the $connection property in the model.

The challenge is, I am not sure how to do unit tests in YY app when the tests involve the Author model. The relevant migration and factory is in the XX app. I can probably copy the migration, factory to YY app and locally that may work since i can get both the apps running while unit testing, so that app YY test can reach the authors table in app XX. But how do i make the tests work in bitbucket pipeline? In pipeline only the app being tested is containerized and runs the tests using it's own db. But since authors table does not exist there, the tests will fail.

Anyone has any experience in similar setup?

P.S.; I am requesting ideas to solve an interesting problem. Not opinions about the setup of the microservice ecosystem only. Although I will definitely welcome that, if you can also share ideas about the main problem i am trying to solve :).

0 likes
11 replies
martinbean's avatar

@yeasir_arafat Why do you have a Laravel app that speaks to a Laravel app? Your users now how to wait for two sequential network calls to complete instead of just one. You’ve literally doubled your response times.

yeasir_arafat's avatar

@martinbean That's what i inherited. But its now slow at all. And the apps do not need to talk to each other for every request. some do. I guess it was done to create a microservice architecture.

martinbean's avatar

@yeasir_arafat It will be slower than it needs to be, though.

Request comes in to App Y. Well, instead of App Y just sending a response, it has to instead make its own request to App X, wait for the response from App X, before App Y can sent its own response to the user who originally made the request.

jlrdw's avatar

I would suggest making a whole new project and not use the two like that. If it's an existing working app why does it need test?

yeasir_arafat's avatar

@jlrdw i do not understand. we write unit tests to make sure something is not broken when we fix a bug or new feature!

jlrdw's avatar

@yeasir_arafat just example, see https://stackoverflow.com/questions/40236528/laravel-unit-testing-show-complete-error

I have seen so many post where a test fails but real code works.

Or cases where test pass but real code doesn't.

The only way to truly know if a section of an app works (for real) is test it for real. I.e., actually add a real record to the database.

I look and do some test (not to see if a method works) but rather looking for areas I can refactor and inprove the code.

Not does my file upload method work. If I can for real upload a file, I am not going to turn around and assert I can do the same.

But just my opinion and gained from so many past post.

Ask yourself, is testing also code?

Yes, okay do you bother writing test that test the test which would need code to test that, etc.

Just remember Chuck Yeager test piloted planes for real (flew them). He didn't "assert" they can fly.

yeasir_arafat's avatar

@jlrdw I do not really know how to respond to your prespective. If your code works in real life but the unit test that tests that part of your app does not work, it means you are writing your tests wrong. This is one of the most common mistakes that i have seen people make when writing tests. Anywayss, that is a different topic. There is a reason for so many tools to test your code being available out there. I am sure I am missing some points from what you are trying to say.

Talinon's avatar
Talinon
Best Answer
Level 51

Tests are only as good as the developer who wrote them. Having said that, any experienced developer would agree that TDD and test coverage is imperative. I would never dream of running anything in production without test coverage.

@yeasir_arafat Yes, I actually do have this similar architecture for an application. I have a main API that is consumed by a few applications. Two of those applications are in-house (or intranet only) used by internal employees. The other app is a customer portal that is publicly accessible. The reason why it has its own Laravel app is for the sake of security. We're talking about an application that is responsible for managing projects worth multi-millions of dollars. We can configure our main API (and firewall) to only accept incoming requests from that public facing server, which basically serves as a proxy or service layer. The database and domain logic is all on our main API, buried behind a firewall. If the public API was ever compromised, there would be no data breach (or minimal) and very little code. This allows us to be very selective in what we expose to the internet.

Again, having all our data and logic in the main API, we really don't need to worry about shared unit testing. The portal API is basically just a delegating relay service. For form validation, I wrote some middleware for Laravel's HTTP client, which intercepts 422 responses, and basically hydrates a validator with all the errors and automatically bubbles it up to the client. It works very smoothly for passing errors directly through the service.

After saying all this, in our case, there is a small need to unit test across multiple databases, such as the user models for external clients/customers. What you were thinking is along the same lines as what we have done. Even though the external users are within another database/app, I created a migration for them that is only ran if the environment is for testing:

public function up()
    {
        if (\App::environment('testing'))
        {

            Schema::connection(config('database.portal'))->create('users', function (Blueprint $table) {
                $table->id();
                $table->string('name');
                $table->string('email');
                $table->string('password');
                $table->timestamps();
            });
        }
    }

As you can see, it only runs if the environment is testing, which would be when phpunit is executed. This will create the migration for testing, but keep it out of any other environment deployment. You can also see on the Schema facade I make a call to connection(), which uses another database connection/configuration so I don't have a collision with the same users table name. This connection would also be set to localhost to simulate the external database for the tests. So the actual migration of the external users table is controlled by the portal application, but here we've faked it for testing. You'd also want to create an entry in phpunit.xml for the database connection. I use an in memory database, so something like:

 <env name="PORTAL_DB_CONNECTION" value="portal_memory_testing"/>
 <env name="PORTAL_DB_DATABASE" value=":memory:"/>

And then add the database configuration to your config/database.php

I then created an abstract model class to use for any of the external models where they override the constructor and set the database connection:

abstract class PortalModel extends Model
{

    public function __construct(array $attributes = [])
    {
    
        parent::__construct($attributes);

        $this->setConnection(config('database.portal'));
    
    }
}

Now this allows me create model classes such as:

class ClientUser extends PortalModel implements ApplicationUser
{

    use HasFactory;

    public function projects()
    {

        $this->connection = env('DB_CONNECTION');

        return $this->belongsToMany(Project::class, 'project_user');
    
    }

And create factories:

class ClientUserFactory extends Factory
{

    protected $model = ClientUser::class;

    protected $connection = 'portal';

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        
        return [
            'name' => $this->faker->name(),
            'email' => $this->faker->unique()->safeEmail(),
            'password' => $this->faker->lexify('?????????'),
        ];
    }

and perform tests:

class ClientUserTest extends TestCase
{

    /** @test */
    public function it_can_belong_to_many_projects()
    {

        $user = ClientUser::factory()->has(Project::factory()->count(3))->create();

        $this->assertEquals(3, $user->projects->count());

    }

This might seem convoluted at first, but once its set up, you don't have to think about it any longer. This app has over 2000 tests and growing, and I haven't given this a second thought in over a year.

Hopefully this helps you.

1 like
yeasir_arafat's avatar

@Talinon Thanks for taking the time to provide an elaborate answer. This is great. Your use case is almost identical to mine. We wanted to use the built in secrity tools available in laravel for the user facing apps. We also wanted to keep these apps separate from the main api service for scalability's sake and also because mostly these apps are dealing with different areas of the business. But they also overlap in some areas, which is where sharing the models come in. It reduces complexity that comes with syncing db tables between two apps - such as data consistency. there may be other and better ways to do it, but this is what we have now.

I had the connection setup in the author model to point to the api db and had the connection defined in db config as well. I dint know the connections can also be defind in the factory classes.

The issue is, if the author model has a foreign key that points to a table which is also external and, the associated model, migration, factory has not been defined in the app(YY app in this example), then there will be error when running unit tests that uses the author model factories. Any suggestion/experience regarding how that can be achieved without duplicating the foreign model, factory, migration etc?

The other missing piece of the puzzle that now i understand was to change the db connection to local db when running unit tests.

Thanks,

Edit: btw, there is a package which has the capacity to use remote databases for running migrations: https://www.code-distortion.net/docs/adapt/remote-databases/

Talinon's avatar

@yeasir_arafat

Any suggestion/experience regarding how that can be achieved without duplicating the foreign model, factory, migration etc?

I don't think there is any way around it, you'd need to create them. In my case, it's mostly just the users table. Our internal applications authenticate against Azure AD, where the client portal uses Laravel's authentication (Fortify/Sanctum), which is yet another reason for having a separate API. It sounds like the main difference is that your YY app has more responsibility and can manipulate the database directly. In my case, the portal app only interacts with data from its own database, and that's strictly just for the users/password resets/personal access token tables. Everything else gets delegated through the main API to handle.

Where it becomes interesting is the user relationships. For example, an internal user (employee) can have many Projects, but also so can an external (customer) user. In those cases, I ended up creating user_id and client_user_id fields for the foreign keys. The application knows which database to use because of the model connection that I shared above. It knows which field to use for the foreign key because laravel conventions, for this specific case, it knows to use the cleint_user_id column. Fortunately, the external portal app only uses a fraction of the application's features, so this solution doesn't pollute many tables.

There's no argument it's a pain initially. As you said, there may be other/better ways, but I found this to work for our case, and once established, it's been solid ever since. What I have found is that when you create the migrations/factories/models, you can get away with just the minimum. I just needed to basically define the ids to be able to create the relationship tests. The main API doesn't care about the email verification fields, etc, and it never will.. so I didn't bother including any of that. I kept things as light as possible just to get the tests working.

1 like
yeasir_arafat's avatar

@Talinon Yup. I have already set these up since we last chatted.

I have created one config file for all the models that depends on a remote db to determine which connection to use based on the env the app is running in. This gives me the fliexibility to share multiple remote db connection, although I may never need it. the config file, In combination with .env/.env.testing files should work without. Will require some more testing, but I like the solution so far.

The adapt package that i linked in my previous comment will take all these extra work away, but only if one app solely depends on another app for ALL it's models. I have used this in a previous project.

Thanks heaps for the help.

Cheers.

Please or to participate in this conversation.