1

I'm trying to write some tests for my code. I'm using dependancy injection, and I'm trying to create a faked version of my database to be used when running tests.

I'm using the keyword implements to define my faked database, however I'm getting typescript errors due to the fact that this faked DB is missing certain properties, however, those properties are private, and never used outside the class

Here's an example:

class Database {
    private client: MongoClient;

    public async getData(query: string): Promise<{ name: string }> {
        return await this.client.db('db').collection('collection').findOne({ name: query });
    }
}

class MockDatabase implements Database {
    public async getData(query: string): Promise<{ name: string }> {
        return {
            name: 'Jo'
        }
    }
}

function makeApp(database: Database) {
    console.log(`Here's your app`);
}
const fakeDB = new MockDatabase();
const app = makeApp(fakeDB)

Typescript will error both when declaring MockDatabase, as well as when using it in the makeApp function.

Property 'client' is missing in type 'MockDatabase' but required in type 'Database'

How should I approach faking a database or another service like this?

Wazbat
  • 53
  • 6
  • 1
    Private properties cannot be ignored for compatibility reasons, see [this q/a](https://stackoverflow.com/a/48953930/2887218). If `makeApp()` doesn't care about whether the private `client` exists or not, then you should make a new supertype of `Database` that ignores it, let's call it `IDatabase`. You can either define it manually or as a function of `Database`, and then you want `makeApp()` to accept an `IDatabase` instead of requiring a `Database`. See [this code](https://tsplay.dev/mxBvKw). Does that meet your needs? If so I can write up an answer; if not, let me know what I'm missing. – jcalz Mar 02 '22 at 16:42
  • @jcalz Sorry for the delay, forgot to send! Really appreciate that example!! Yep that worked perfectly. After my own experimentation I ended up making a `DatabaseService` interface that both classes used with `implements`, however I had to write every method three times in total. Your example there is perfect and works great and saves me having to do that – Wazbat Mar 07 '22 at 10:26

1 Answers1

1

A Database needs to have the client property, and because the property is private, that means you can only get a valid Database from the Database constructor. There is no way to "mock" a Database with a different declaration, because private properties need to come from the same declaration in order to be compatible. This restriction is important, because private properties are not completely inaccessible from outside the object; they are accessible from other instances of the same class. See TypeScript class implements class with private functions for more information.


Anyway, instead of trying to mock a Database, you should consider creating a new interface which is just the "public part" of Database. It would look like this:

// manually written out
interface IDatabase {
  getData(query: string): Promise<{ name: string }>
}

You can make the compiler compute this for you, because the keyof operator only returns the public property names of an object type:

// computed
interface IDatabase extends Pick<Database, keyof Database> { }

The type Pick<Database, keyof Database> uses the Pick<T, K> utility type to select just the public properties of Database. In this case that's just "getData", and so the computed IDatabase is equivalent to the manual one.


And now we change references to Database to IDatabase anywhere we only care about the public part:

class MockDatabase implements IDatabase {
  public async getData(query: string): Promise<{ name: string }> {
    return {
      name: 'Jo'
    }
  }
}

function makeApp(database: IDatabase) {
  console.log(`Here's your app`);
}
const fakeDB = new MockDatabase();
const app = makeApp(fakeDB)

And everything works as expected.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • To expand on this, you can also inherit the types from the interface: `getData: IDatabase['getData'] = async (query) => {` instead of `public async getData(query: string) :Promise<{ name: string> {` You can even set the value to a jest.fn for example, to make testing even easier – Wazbat Mar 22 '22 at 01:03