Where's Waldo: Great for Children's Books, Hell for Spec Files

Share
Where's Waldo: Great for Children's Books, Hell for Spec Files
  1. Tight coupling
  2. Prolific use of any for (cntrl + alt + shift <insert name of property> to search entire codebase for where property is set)
  3. And finally, no clearly defined patterns or practices (the practice isn't obvious, or really semi-apparent, I have to look for a fairly common approach and adopt it so I don't look like a noob).

What do these have in common? I could give you some theory on why each problem breaks some sort of readability/maintainability/SOLID-principle-y rule, but really, it's a game of Where's Waldo. You find yourself saying "I'm not sure, let me make sure. I don't want this to break. Is it being used over there? I don't want to do it differently than everyone else." These questions are pauses in concentration, which are usually preventable and a very common source of frustration.. You could say it's an eyesore. I’d say it's a brain sore, a complete waste of time which has two outcomes: your stories are taking you longer and costing your company money, or, you're saying "Please God, don't let them give me that story on the messy as hell component". And on dumb luck, you’re working on the weekend because "5 points for you should take as long as 5 points for me".

If the code can be made such that I'm asking these questions as little as possible, why not do it—even if making the code up front takes longer? I break this down into two principles that determine the "Where’s Waldo Factor":

  1. Ambiguity in the use, scope, and impact of the object or functionality in question
  2. Ambiguity in the pattern or best practice

Some of you get the big picture and put all the "good code" clichés into practice. You keep your developers from playing Where's Waldo. This isn't anything new. My question is, if half (maybe one-third?) of your TypeScript code is spec files, and equal if not more time is spent on it, why would letting your devs play Where's Waldo there be any less damaging?

Waldo (our enemy) loves more options

Dependencies in components and services means providers, spies and mocks. As you probably know by now, here are just a few:

The **Mock Class** approach (for `ProfileService`)
class MockProfileService {
  updateProfile(profile: any) {
    return of(true);
  }
}

beforeEach(async () => {
  await TestBed.configureTestingModule({
    // Imports
    providers: [
      { provide: ProfileService, useClass: MockProfileService },
      // Other providers
    ]
  }).compileComponents();
  // Other remaining component setup
});
The **useValue** object approach (for `CountryService`)
const mockCountryService = {
  getCountries: () => of(['USA', 'UK', 'Canada'])
};

beforeEach(async () => {
  await TestBed.configureTestingModule({
    // Imports
    providers: [
      { provide: CountryService, useValue: mockCountryService },
      // Other providers (mock classes / spies)
    ]
  }).compileComponents();
  // Other remaining component setup
});
The **spyOn** approach (for `UserService`)
let injectedUserService: UserService;

beforeEach(async () => {
  await TestBed.configureTestingModule({
    // Imports
    providers: [
      UserService,
      // Other providers (mock classes / useValue, etc.)
    ]
  }).compileComponents();

  // Other remaining component setup
  injectedUserService = TestBed.inject(UserService);
});

it('should patch user data to form on init', () => {
  spyOn(injectedUserService, 'getUser').and.returnValue(of({
    name: 'Jane Doe',
    email: 'jane@example.com',
    country: 'USA'
  }));
  fixture.detectChanges();
  expect(component.userForm.value).toEqual({
    name: 'Jane Doe',
    email: 'jane@example.com',
    country: 'USA'
  });
});

Jest and Jasmine offer lots of flexibilities, and many ways to skin a cat, unfortunately, in the wrong hands—often the case with front-end developers, it feeds into a mindset that's all too common the front end community: Novelty-seeking programming.

🐿️
Front End JavaScript developers tend to embrace the "I'm creative/quirky mindset", they bring in the mechanical keyboards, dress up like the characters in Silicon Valley, and will not only have purple themed VS Code, but will have purple themed browsers...most importantly though, they love new tools, the more slightly nuanced tools to tailor fit just the precise problem I'm dealing with, the better. Think about it: the vast amount of npm packages, the loosey-goosiness of JavaScript, the endless RXJS possibilities of chaining and combining observables (or throw await on a promise in the mix), new versions to their respective framework coming out every 6 to 12 months, they try to maintain the "creative front-end credo" by saying, "I know 100 different tools for the job, and today, I feel like this is the perfect one for this job."

This is in constant conflict with the notion of "best practice", which feeds into my second 'Where’s Waldo Factor": Ambiguity in the pattern or best practice.

As my SQL Server instructor at Collin Community College once said, "Just because you can, doesn't mean you should."

What does this 'Where's Waldo' look like in our spec files?

Take this UserProfileComponent, which has three services dependencies. All three return an observable with data, one of them, CountryService, has business logic performed on the data it returned.

export class UserProfileComponent {
  userForm: FormGroup;
  user$: Observable<any>;
  countries$: Observable<string[]>;
  processedCountries$: Observable<string[]>;
  submitMessage: string = '';

  constructor(
    private fb: FormBuilder,
    private userService: UserService,
    private countryService: CountryService,
    private profileService: ProfileService
  ) {
    this.userForm = this.fb.group({
      name: [''],
      email: [''],
      country: ['']
    });

    this.user$ = this.loadUser();
    this.user$.subscribe(user => {
      this.userForm.patchValue(user);
    });

    this.countries$ = this.loadCountries();
    this.processedCountries$ = this.processCountryData();
  }

  loadUser(): Observable<any> {
    return this.userService.getUser();
  }

  loadCountries(): Observable<string[]> {
    return this.countryService.getCountries();
  }

  processCountryData(): Observable<string[]> {
    return this.countries$.pipe(
      map(countries => countries.filter(c => c.startsWith('U')))
    );
  }

  submitProfile() {
    if (this.userForm.valid) {
      this.profileService.updateProfile(this.userForm.value).subscribe({
        next: () => this.submitMessage = 'Profile updated successfully!',
        error: () => this.submitMessage = 'Profile update failed.'
      });
    }
  }
}

Now let's look our quirky, novelty-loving JavaScript developer's test case, which you already got a preview of before, notice that we have a unique approach for solving each dependency.

class MockProfileService {
  updateProfile(profile: any) {
    return of(true);
  }
}

describe('UserProfileComponent', () => {
  let component: UserProfileComponent;
  let fixture: ComponentFixture<UserProfileComponent>;
  let injectedUserService: UserService;

  const mockCountryService = {
    getCountries: () => of(['USA', 'UK', 'Canada'])
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [UserProfileComponent, ReactiveFormsModule],
      providers: [
        UserService,
        { provide: CountryService, useValue: mockCountryService },
        { provide: ProfileService, useClass: MockProfileService }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(UserProfileComponent);
    component = fixture.componentInstance;
    injectedUserService = TestBed.inject(UserService);
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should patch user data to form on init', () => {
    spyOn(injectedUserService, 'getUser')
      .and.returnValue(of({
        name: 'Jane Doe',
        email: 'jane@example.com',
        country: 'USA'
      }));
    fixture.detectChanges();
    expect(component.userForm.value).toEqual({
      name: 'Jane Doe',
      email: 'jane@example.com',
      country: 'USA'
    });
  });

  it('should process countries and only show those starting with U', () => {
    fixture.detectChanges();
    component.processedCountries$.subscribe(countries => {
      expect(countries).toEqual(['USA', 'UK']);
    });
  });

  it('should show success message on successful profile update', () => {
    component.userForm.patchValue({
      name: 'Test',
      email: 'test@test.com',
      country: 'USA'
    });
    component.submitProfile();
    expect(component.submitMessage).toBe('Profile updated successfully!');
  });
});

Count them—three total. Just to review:

The **useValue** object approach (for `CountryService`)
{ provide: CountryService, useValue: mockCountryService }
The **useClass** object approach (for `MockProfileService`)
{ provide: ProfileService, useClass: MockProfileService }
The **spyOn** object approach (for `UserService`)
injectedUserService = TestBed.inject(UserService);

The obvious problem: As we work with components that get larger and larger, I'm asking myself "which form mocking is just right for my job, which three (and let's be honest, real world we may be talking 4) approaches do I want". If I'm adding a feature to the component but not necessarily with a new service/dependency I'm asking, "Ok, how was this one mocked, and if it were spied on, how was it spied on" (proceed to search individual test cases for how the spied-on service was mocked...lovely)

One Obvious Answer, One not so obvious

Okay, first things first—say you’ve got a big spec file, you can use two different mocking approaches: a 'go-to', and a backup when the backup is better, keeping Waldo at bay—and not confusing the reader. Okay—three, if it’s a bear of a use case and you'll only use it as often as !important in your css. So the theme is consistency, and predictability, of course.

"You just pointed an example of using three approaches and said it was bad". This is to make a point about playing a guessing game on a small scale. As you test 600 line components (that length alone potentially being a refactor opportunity), choosing four or five.

But here's second thing, when possible, mock everything in the TestBed/BeforeEach, and mock the happy path. Why?

  • If I'm adding a feature to an existing component/service, and the spec file is already in place, coded with the happy path mocked, I know in the back of my mind that the default behavior will take place each time I write a test case.
  • I sepearate concenrs, default dependency behavior in the Testbed setup, and outcomes based on the behaviors in the test cases. This saves mental context switching. I can get a feel for what the default behavior of the components dependency is first by looking at.

Here is what this looks like. I want to you notice not only the uniformity, but as a side note, the use of jest.fn(), and why. First I'm going to show you the setup. Then the test cases.

describe('UserProfileComponent', () => {
  let component: UserProfileComponent;
  let fixture: ComponentFixture<UserProfileComponent>;

  const mockCountryService = {
    getCountries: jest.fn(),
  };

  const mockUserService = {
    getUser: jest.fn(),
  };

  const mockProfileService = {
    updateProfile: jest.fn(),
  };

  beforeEach(async () => {
    mockCountryService.getCountries.mockReturnValue(
      of(['USA', 'Canada', 'UK', 'Australia', 'Germany'])
    );
    mockUserService.getUser.mockReturnValue(
      of({ name: 'Jane Doe', email: 'jane@example.com', country: 'USA' })
    );
    mockProfileService.updateProfile.mockReturnValue(of({ success: true }));

    await TestBed.configureTestingModule({
      imports: [UserProfileComponent, ReactiveFormsModule],
      providers: [
        UserService,
        { provide: CountryService, useValue: mockCountryService },
        { provide: ProfileService, useValue: mockProfileService },
        { provide: UserService, useValue: mockUserService },
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(UserProfileComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  afterEach(() => {
    jest.clearAllMocks();
    jest.resetAllMocks();
  });
  

Here's the side note: I like making object literals with jest.fn() for my mock classes, if I ever wanted to verify a method was called from a mock, it would look something like expect(mockProfileService.updateProfile).toHaveBeenCalled(), no new spy variable created in the test case, no const spy = jest.spyOn(mockProfileService, 'updateProfile'), everything I need is in this mock services I declared earlier, which is what you see below.

 const mockProfileService = {
    updateProfile: jest.fn(),
  };

Now on to the test cases:

it('should create', () => {
  expect(component).toBeTruthy();
});

it('should patch user data to form on init', () => {
  expect(component.userForm.value).toEqual({
    name: 'Jane Doe',
    email: 'jane@example.com',
    country: 'USA',
  });
});

it('should process countries and only show those starting with U', () => {
  component.processedCountries$.subscribe(countries => {
    expect(countries).toEqual(['USA', 'UK']);
  });
});

it('should show success message on successful profile update', () => {
  component.submitProfile();
  expect(component.submitMessage).toBe('Profile updated successfully!');
});

it('should show error message on profile update failure', () => {
  mockProfileService.updateProfile.mockReturnValue(
    throwError(() => 'Profile update failed.')
  );
  component.submitProfile();
  expect(component.submitMessage).toBe('Profile update failed.');
});
});

Looking at the test cases: Only one time did I need to manually spy on mockProfile service, in the last test case, because I wanted to deviation from the default behavior. Other than that, my focus is on verify, verify, verify, no setup. You will be astounded how much you fly through the test cases.

Putting a Bow On it

Mocking is a pain in the ass, but honestly, a slightly smaller pain in the ass if it's 'front loaded' into the setup. I'd say it's not only easier for the code writer to organize his or her thoughts, but significantly easier for the reader to pick up what's going on, and add more features (and consequently test cases) with less mental load: as in decision fatigue and code searching. This is a prime example of why best practices aren't some arbitrary rules carried out by some bitter fuddy duddy at a fortune 500 who needs everything to look a certain way, it has implications for the developer experience.

Embrace boring and predictable, save the creativity and craftmanship for code structuring, flexing your OOP principles and SOLID principles.

SPONSORED

Enjoyed this breakdown? Subscribe for more no-fluff Angular testing insights.

Subscribe