Tests, MVVM, and AI — Why Clean Layers Make All Three Work

blog image

Every codebase intends to have tests. Most have a __tests__ folder that hasn't been touched in eight months. The reason isn't laziness. It's that the code isn't shaped for testing. A component that fetches data, validates a form, formats the result, and decides where to navigate next can't be tested without simulating the entire world — a real router, a mounted DOM, intercepted HTTP calls, mocked child components. For one button click.

MVVM creates a different shape. This article shows what that shape looks like as test files, and where AI actually fits in.

This is the third article in the series. The first covered why the layers exist. The second traced a single button click through all four layers. That article ended with one sentence: "The test file is the receipt." This one makes good on that.

The receipt mental model

A test is a receipt: proof that a specific contract was honored. useInvestAmount.test.ts is a receipt for: these form inputs → this API payload → this route. When you open that file, you know exactly what the feature promises to do.

That receipt is only writable if the business logic is somewhere you can reach it. fundingPayload assembles an ACH object from four separate componentData fields — accountNumber, routingNumber, accountHolderName, accountType — and maps them to account_number, routing_number, account_holder_name, account_type. Every field name, every mapping. When that logic lives in the ViewModel, the test is a function call. When it lives in the template, you need a mounted component tree and a filled form to get anywhere near it.

The layers don't exist to make code pretty. They exist to make it reachable.

Seams — what MVVM actually gives you

A seam is a place where you can substitute a behavior without modifying production code. That's what layer boundaries give you.

The ViewModel calls useRepositoryInvestment(). In production, that returns the real Pinia store. In a test, vi.mock('InvestCommon/data/investment/investment.repository') intercepts it and returns whatever object you provide. The ViewModel never knows the difference — it just calls useRepositoryInvestment() and gets back something with the right shape.

That "something" looks like this:

ts
const mockInvestmentRepository = {
  setAmount: vi.fn().mockResolvedValue(undefined),
  setAmountState: ref({ loading: false, error: null as any }),
  setAmountOptionsState: ref({ data: {} }),
  getInvestUnconfirmedOne: ref({}),
};

The entire Pinia store — loading states, error states, API calls — replaced by six lines. No Pinia setup. No real HTTP. No database. Just an object with the fields the ViewModel needs.

Each layer boundary creates one of these seams:

  • Service boundary: vi.mock(apiClient) at the module level. The repository test never touches a real network.
  • Repository boundary: a plain mock object with ref() fields and vi.fn() methods. The ViewModel test never reads real store state.
  • ViewModel boundary: the composable is a plain TypeScript function. Call it directly. No DOM. No component tree.

If setAmount were a raw fetch() call inside the component, there'd be no useRepositoryInvestment to intercept. You'd need msw, a test server, or a global HTTP mock. Every test would become an integration test whether you wanted it to or not.

What the ViewModel test actually asserts

useInvestAmount depends on ten things: a router, a route, a global loader, a HubSpot form, a session store, and five repositories. The test setup mocks all of them in a beforeEach block. That block is about 60 lines. It looks heavy the first time.

The insight that makes it manageable: the structure is identical for every ViewModel in the codebase. Once you've written it for useInvestAmount, writing it for useInvestSignature is copy-paste-adjust. The mock object shapes differ. The structure does not.

Here's the happy-path test:

ts
it('submits combined payload and navigates on successful handleContinue', async () => {
  composable.amountFormRef.value = {
    isValid: true, onValidate: vi.fn(), scrollToError: vi.fn(),
    model: { number_of_shares: 20 }, investmentAmount: 2000,
  } as any;

  composable.ownershipFormRef.value = {
    isValid: true, onValidate: vi.fn(), scrollToError: vi.fn(),
    model: { profile_id: 7 },
  } as any;

  composable.fundingFormRef.value = {
    isValid: true, onValidate: vi.fn(), scrollToError: vi.fn(),
    model: { funding_type: FundingTypes.wallet },
    componentData: { isInvalid: false, accountHolderName: '', accountType: '', accountNumber: '', routingNumber: '' },
  } as any;

  await composable.handleContinue();

  expect(mockInvestmentRepository.setAmount).toHaveBeenCalledWith(
    'test-slug', '123', '456',
    expect.objectContaining({ number_of_shares: 20, profile_id: 7 }),
  );

  expect(mockRouter.push).toHaveBeenCalledWith({
    name: ROUTE_INVEST_SIGNATURE,
    params: expect.objectContaining({ profileId: '7' }),
  });
});

Notice profileId: '7'. The route params contain profileId: '456' — the ID the user arrived with. The form has profile_id: 7 — the ID the user selected. The navigation must push the form value, not the route param. That's a specific bug class: silent wrong-value passing that ships through code review, passes visual QA, and only breaks in production when a user switches their profile mid-flow.

This test catches it. The assertion fails if you write route.params.profileId instead of String(dataToSend.profile_id) in the ViewModel. No other testing method catches this without the full multi-step flow wired up.

Now the ACH branch:

ts
it('builds ACH funding payload with payment data', async () => {
  // ...form refs omitted for brevity...
  composable.fundingFormRef.value = {
    isValid: true, onValidate: vi.fn(), scrollToError: vi.fn(),
    model: { funding_type: FundingTypes.ach },
    componentData: {
      isInvalid: false,
      accountHolderName: 'John Doe',
      accountType: 'checking',
      accountNumber: '1234',
      routingNumber: '5678',
    },
  } as any;

  await composable.handleContinue();

  expect(mockInvestmentRepository.setAmount).toHaveBeenCalledWith(
    'test-slug', '123', '456',
    expect.objectContaining({
      funding_type: FundingTypes.ach,
      payment_data: {
        account_number: '1234',
        routing_number: '5678',
        account_holder_name: 'John Doe',
        account_type: 'checking',
      },
    }),
  );
});

Every field name is asserted. routing_number, not routingNumber. account_holder_name, not accountHolderName. This test fails if you mismap one camelCase field to its snake_case counterpart in the payload builder. That failure happens in milliseconds in a CI run, not after a QA cycle.

One more: reactive state. When the user changes the selected profile mid-form, the funding method should reset and both wallet repositories should reload for the new profile:

ts
it('resets funding method and reloads wallets when profile_id changes', async () => {
  const fundingRef = {
    isValid: true, onValidate: vi.fn(), scrollToError: vi.fn(),
    model: { funding_type: FundingTypes.wallet },
    componentData: { isInvalid: false, accountHolderName: '', accountType: '', accountNumber: '', routingNumber: '' },
  } as any;

  composable.fundingFormRef.value = fundingRef;
  composable.formModel.value.profile_id = 42;
  await nextTick();

  expect(fundingRef.model.funding_type).toBeUndefined();
  expect(mockWalletRepository.getWalletByProfile).toHaveBeenCalledWith(42);
  expect(mockEvmRepository.getEvmWalletByProfile).toHaveBeenCalledWith(42, []);
});

Two lines of setup. Three assertions. This watcher behavior is completely invisible to a snapshot test or a visual walkthrough. It only surfaces when a user actually switches profiles in the middle of filling out the form.

What the Repository test asserts

The repository test looks nothing like the ViewModel test. No form refs. No router. No composable call.

withActionState is the helper that every repository action in the app runs through — it manages the loading/error/data lifecycle automatically. Test it once:

ts
it('sets loading true then updates to success state and returns result', async () => {
  const state = createActionState<{ id: number }>();
  const result = await withActionState(state, async () => ({ id: 99 }));

  expect(result).toEqual({ id: 99 });
  expect(state.value).toEqual({ data: { id: 99 }, loading: false, error: null });
});

it('sets loading true then error state and rethrows on failure', async () => {
  const state = createActionState<number>();
  const err = new Error('fail');

  await expect(
    withActionState(state, async () => { throw err; }),
  ).rejects.toThrow('fail');

  expect(state.value).toEqual({ data: undefined, loading: false, error: err });
});

No mocks. No Pinia. Pure functions. This is what "centralizing the loading lifecycle" actually buys you in testing terms: you test the lifecycle once, here, and every higher layer trusts it. The ViewModel test doesn't re-test whether setAmountState.loading transitions correctly — it assumes withActionState works, because these tests say it does.

The offer repository test takes a different shape. ApiClient is mocked at the module level with vi.hoisted() — a Vitest utility that hoists the mock setup before imports run. The test then calls store.getOffers() followed by store.getOfferOne(slug) and asserts that the detail fields from the second call replaced the list data in the right state slot:

ts
expect(store.getOfferOneState.data?.description).toBe('Full description from the detail endpoint');
expect(store.getOfferOneState.data?.highlights).toBe('Detail highlights');

This test is about one specific thing: did the data land in the right place after the API call. Not whether the HTTP request was formed correctly — that's the Service's job. Not whether the View rendered it — that's the component test's job.

The fat component contrast

ViewOffersDetails.test.ts is 330 lines. It uses vi.hoisted() for seven separate mock functions, a createRawOffer() factory that constructs a full offer object with every field, child component stubs, mount(), and flushPromises().

That test is worth writing. It covers rendering behavior, slot contracts, offline fallback scenarios — things the ViewModel test can't reach. But it is not the right tool for testing whether payment_data.routing_number is assembled correctly.

ViewModel testComponent test
Setupmock objects, beforeEachmount(), child stubs, data factories
Coversbusiness logic, payload shape, navigationrendering, slots, visual states
Speedfastslower
Breaks whena contract changesthe DOM structure changes

When business logic lives in the ViewModel, you cover it with the fast path. If that logic were in the component instead, you'd need the heavy path just to reach it — and the component test would be doing double duty: testing rendering and testing payload assembly in the same run. When a test breaks, you'd have to figure out which one changed.

AI and testing — the honest version

AI doesn't write tests. It writes first drafts of the mechanical parts.

What's mechanical: the vi.mock() wiring, the mock object shapes, the beforeEach setup, the it() description strings, the happy-path assertion scaffolding. Given useInvestAmount.ts as input, a model can generate the correct mockInvestmentRepository object with all six fields and the right ref() shapes. It knows the pattern because the TypeScript types tell it what fields exist. That setup used to take 45 minutes of reading source files. With AI it takes five minutes plus review.

Where AI fails:

The profileId: '7' problem. The route has profileId: '456'. AI sees that. It generates expect.objectContaining({ profileId: '456' }) in the navigation assertion. The test passes. The bug ships. AI doesn't know that the navigation must use the form's profile_id — that requires reading the business rule, not the types.

expect.objectContaining({}). An empty object always matches. AI generates it when it doesn't know what the payload should contain. A test with expect.objectContaining({}) proves the function was called. It does not prove the function was called correctly. That's a receipt with no amount on it.

nextTick() placement. The profile watcher test needs await nextTick() after mutating formModel.value.profile_id, before the assertions. AI sometimes places it after the assertions. The test passes — for the wrong reason.

What to test at which layer. Ask AI to write tests for a ViewModel and it will generate tests for CSS-class logic, slot props, and rendering behavior. Those belong in the component test. AI doesn't have an architectural opinion about where a test belongs. You do.

The workflow that works:

Give AI the ViewModel source file and ask for the mock setup and beforeEach block. Review every ref() shape and vi.fn() return value — AI gets these right most of the time, but a missing field in a mock object produces a confusing runtime error later. Write the assertions yourself for any test covering a domain rule: payload shape, conditional branches, navigation targets, watcher side effects. Let AI fill in the it() description strings and the happy-path scaffolding once you've specified the assertion.

The cleaner the interfaces, the better AI performs. A ViewModel with typed inputs and typed return values gives AI everything it needs to generate correct mock shapes. A 400-line composable with mixed concerns gives AI a shape it can't fully describe — and the tests it generates will reflect that.

What a complete test suite looks like for one feature

The investment amount step has test coverage at three layers:

  • repository.test.tswithActionState contract: four tests, pure functions, zero mocks required
  • offer.repository.test.ts — Repository integration: ApiClient mocked at module level, state transitions asserted
  • useInvestAmount.test.ts — ViewModel logic: eight tests, every meaningful branch, no DOM
  • ViewOffersDetails.test.ts — Component rendering: mount, stubs, slot contracts, offline fallback

Each layer tests only what it owns. The ViewModel test does not re-test withActionState — it trusts the repository test. The component test does not re-test handleContinue — it trusts the ViewModel test.

When a test starts asserting things from two layers away, something leaked. That's the signal. The layers exist so each test can stay in its lane and mean what it says.

Issuance Is Solved: Any Asset Manager Can Put a Fund On-Chain Today

article Issuance Is Solved: Any Asset Manager Can Put a Fund On-Chain Today image

Handle million requests per second during rush hours

article Handle million requests per second during rush hours image

Creating SVG Animation With Lottie

article Creating SVG Animation With Lottie image