testdouble / testdouble.js

A minimal test double library for TDD with JavaScript
MIT License
1.41k stars 142 forks source link

How to properly create object for testing? #482

Closed JuanCaicedo closed 2 years ago

JuanCaicedo commented 2 years ago

Hi Justin and team! Hope you're all doing well πŸ˜„

Description

I would like to create a dummy object that satisfies types in my test and allows me to target different cases in my tests.

Issue

Say I have a type defined

type Dog = {
  name: string
  age: number
}

And I have a function that depends on this type

function hasGreatName(dog: Dog): boolean {
  if (dog.name === 'Spot') {
     return true
  }
  return false
}

I would like to satisfy the Dog type in my tests, and also assert a specific behavior.

These are the two ways I imagined doing that

describe('hasGreatName', () => {
  it('returns true if the dog is named Spot', () => {

    // fails TS compilation with the following error
    // > Property 'age' is missing in type '{ name: string; }' but required in type 'Dog'.
    const dog1 = td.object<Dog>({
      name: 'Spot'
    })
    expect(hasGreatName(dog1)).toEqual(true)

    const dog2 = td.object<Dog>()
    // Throws a TD warning at run time
    // > Warning: testdouble.js - td.replace - property "name" [test double for ".name"] (Function) was replaced with "Spot", which has a different type (String).
    td.replace(dog2, 'name', 'Spot')
    expect(hasGreatName(dog2)).toEqual(true)
  }) 
})

The part about name being a Function really confuses me πŸ˜…

Let me know if I'm doing something wrong, or if there's something to address in the lib that I could help out with! Thanks y'all πŸ˜€

Environment

Repl.it Notebook

https://replit.com/@JuanCaicedo1/TDTestObjects2

JuanCaicedo commented 2 years ago

I know it would be possible to fix the TS compilation on dog1 by adding an age, but in my code that's specifically what I'm trying to avoid πŸ˜€ The object I want to fake is a type from a third party and I would need to define a ton of things in order to satisfy the type

searls commented 2 years ago

The literal reason you're seeing this is probably because td.object generates functions, not static values, of the names passed to it.

In ~13 years (yikes) of practicing TDD on-and-off-again with mocks, I have become pretty fixed in my conviction that values (stuff with name and age like your Dog) should have only elucidative behavior that describes the data they hold, and not any actual feature/business logic. As a result, I never mock them, I always instantiate real ones in my tests. If it's too hard to instantiate those values, then it usually means they're too complicated and I take that feedback to simplify or break down. So for that reason, I'd recommend just instantiating a dog and passing it.

Second, I only fake dependencies that expose some kind of feature/business functions that do the work needed by the subject under test. That means I'd be more likely to replace a module that had a blowDry(dog) function that either transformed or mutated the dog and stub its return value or verify it was called, respectively.

JuanCaicedo commented 2 years ago

Hi Justin! Thanks for the guidance πŸ˜„

Do you think this API would make sense? If so I think that would unlock my use case, even if it's not the best practice πŸ˜…

const partialDog: Partial<Dog> = {
  name: 'Spot'
}
const mockDog = td.object<Dog>(partialDog) 
mockDog.name // 'Spot'
mockDog.age // undefined

In case that's not possible, I'll add a few questions on the rest of your answer πŸ˜€ Thanks!

JuanCaicedo commented 2 years ago

The impression I get is the above already works at runtime. If that seems okay, then perhaps it would be possible to change main/index.d.ts#L224-L232 to the following

/**
 * Create a fake object that is deep copy of the given object.
 *
 * @export
 * @template T
 * @param {Partial<T>} object Object to copy.
 * @returns {DoubledObject<T>}
 */
export function object<T>(object: Partial<T>): DoubledObject<T>;

Let me know what you think of that πŸ˜€ I can try it against my tests to see how it would work

searls commented 2 years ago

This might technically work, but I'm still fuzzy on the problem being solved here. As far as I can tell there are at least two issues:

  1. Faking a value object using td.object that contains some number of static properties that must be set for the backing typescript type to be considered valid. As I wrote above, faking value objects isn't IMO a good idea, and instead real value objects should be used whenever possible. (And to the extent that values & application behavior are inter-mingled, I'd take that as a cue to disentangle them.) As a result, I'm not particularly motivated to make any changes to support that usage

  2. Trying to pass a partial thing to td.object so each and every property doesn't have to be satisfied before the object is copied and a fake is returned. (I don't use Typescript, but I imagine that's what Partial<T> would be doing, and is a built-in type of the language.) To me, this seems to violate type safety -- any time I'm creating and passing a not-quite-valid version of an object to a function, the primary benefit of a type system is the compiler telling me "yo that Dog over there isn't valid". I would think (and when I use mock objects in typed languages, I personally practice) that I'd actually want my tests to be a way to flesh out my types, which requires that all instances be valid and complete.

I'm speculating a lot above, but am I off base here? Is td.object in particular different from other functions in the library such that it only makes sense in this case or would you apply this to every td function?