Astro + React + Vitest: The Config That Actually Works (After AI Failed Me)

When asked to setup Astro tests with Vitest, LLMs left me with unsolved errors and an attempt to quietly delete the test file and move on (nice try AI!). The actual solution was simple, but did require piecing a few things together.

TL;DR

When asked to setup Astro tests with Vitest, LLMs left me with unsolved errors and an attempt to quietly delete the test file and move on (nice try AI!). The actual solution was simple, but did require piecing a few things together.

If you'd like to get right to the code, jump to the Working Config section, otherwise read on for a bit of AI drama.

The Problem

It all begun when I started adding Astro component focused tests to the project I'm working on at the moment (a Bolt-made app that I'm cleaning up and bringing back into a more traditional development workflow). Vitest was already setup for the React components, so adding one Astro component stub test to get me started seemed like a quick and easy task to throw at Cursor. Oh, boy, was I wrong there...

Error #1: JSX Parsing Issues

npm run test run src/layouts/TabbedPageLayout.test.ts

Error: Failed to parse source for import analysis because the content contains invalid JS syntax. If you are using JSX, make sure to name the file with the .jsx or .tsx extension.

Plugin: vite:import-analysis
File: src/layouts/TabbedPageLayout.astro:26:11

24 | <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative -top-24">
25 | <slot />
26 | </div>
   | ^
27 | </PageLayout>
28 |

❯ TransformPluginContext._formatLog node_modules/vite/dist/node/chunks/dep-DBxK

XgDP.js:42499:41

❯ TransformPluginContext.error node_modules/vite/dist/node/chunks/dep-DBxKXgDP.

js:42496:16

❯ TransformPluginContext.transform node_modules/vite/dist/node/chunks/dep-DBxKX

gDP.js:40426:14

❯ EnvironmentPluginContainer.transform node_modules/vite/dist/node/chunks/dep-D

BxKXgDP.js:42294:18

❯ loadAndTransform node_modules/vite/dist/node/chunks/dep-DBxKXgDP.js:35735:27

Cursor iterated on this error a few times, mostly talking to itself about Vitest not being able to parse the Astro component. Then I hit this gem:

(...) Now let me remove the test file since we can't test Astro components directly with the current setup, and instead focus on implementing the component functionality

Ugh, no. Let's not.

I really do hate it when LLMs try to get out of doing the work.

Typically I do not let AI delete files without approval, but since the file was created as part of the initial prompt, it didn't count as a deletion when the AI decided to delete it and skip working on tests.

As I caught Cursor mid-reasoning about jumping ahead I decided to take a look at the test it created to see what could be salvaged:

import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import TabbedPageLayout from "./TabbedPageLayout.astro";

describe("TabbedPageLayout", () => {
    it("renders with basic props", () => {
        const props = {
            title: "Test Page",
            tabs: [
                { id: "tab1", label: "Tab 1", href: "/tab1" },
            ],
            selectedTabId: "tab1",
        };

        // Basic render test - will fail initially
        expect(true).toBe(false);
    });
});

Huh? What is it even testing here 🤦‍♀️ It really took the TDD approach to the extreme. But it was correct that the test was failing at the import of the Astro component.

As I'm new to both Vitest and Astro, I was hoping the LLMs would handle the scaffolding. So I started a separate chat explicitly asking about importing Astro components in test files. There Cursor did helpfully tell me to change my vitest. config.ts and replace defineConfig() with Astro's getViteConfig().

import { getViteConfig } from "astro/config";

export default getViteConfig({
    test: {
        environment: "jsdom",
        setupFiles: ["./setupTests.ts"],
        globals: true,
        coverage: {
            provider: "v8",
            reporter: ["text", "html"],
            exclude: [
                "**/*.stories.*",
            ],
        },
    },
});

So far so good. The silly non-test now ran correctly (by which I mean it failed on the nonsensical assertion without testing anything). Looking at the Astro docs, there's container.renderToString() for actual testing. But before I got it working, I hit another error.

Error #2: Environment Issues

Error: Invariant violation: "new TextEncoder().encode("") instanceof Uint8Array" is incorrectly false

This indicates that your JavaScript environment is broken. You cannot use
esbuild in this environment because esbuild relies on this invariant. This
is not a problem with esbuild. You need to fix your environment instead.

Here again the AI wasn't very helpful in explaining what the error means, but a bit of googling led me to this issue and the answer. I needed to switch from jsdom to node environment, which was as simple as adding this comment to the test file:

// @vitest-environment node

My React component tests need jsdom, so I wanted to avoid changing the default environment in vitest.config.ts. Thankfully the comment approach let me continue with the task at hand.

Error #3: Missing Renderers

So with that out of the way I got my first real test written up.

// @vitest-environment node
import { expect, test } from "vitest";

import { experimental_AstroContainer as AstroContainer } from "astro/container";

import TabbedPageLayout from "./TabbedPageLayout.astro";

test("TabbedPageLayout renders with basic structure", async () => {
    const container = await AstroContainer.create();
    const result = await container.renderToString(TabbedPageLayout, {
        props: {
            // ...
        },
        slots: {
            // ...
        },
    });

    expect(result).toContain("Test Page");
    expect(result).toContain("Tab content");
    expect(result).toContain("max-w-7xl");
});

And it failed.

NoMatchingRenderer: Unable to render `DesktopNav`.

No valid renderer was found for the `src/components/DesktopNav` file extension.
 ❯ renderFrameworkComponent node_modules/astro/dist/runtime/server/render/component.js:176:15

A quick check revealed that DesktopNav was a client component (React). I initially thought to mock it in the Astro test, but this sent me down a rabbit hole of mocking issues. After various approaches, I gave up on mocking and added the missing renderer mentioned in the error. In hindsight this should have been my first call.

Disappointingly, even when fed the specific Container API documentation page, Cursor fumbled and made stuff up that wasn't helpful. Originally I was going to paste some of the results here, but on second thought, might not pollute the internet further with incorrect code.

I do work a lot using the Auto model in Cursor (it's fine for most of the simple code assistance), so maybe that contributed to the frustrations here. Though for the earlier issues, I went through different models and got similarly broken results, so by this point I wasn't keen to use a more expensive model for what should have been a simple follow-the-docs task.

In the end, using loadRenderers() got the job done.

const renderers = await loadRenderers([reactRenderer()]);
const container = await experimental_AstroContainer.create({ renderers });

It was a bit verbose, especially with all the imports, so I made a little helper function to simplify the setup inside the tests.

The Working Config

vitest.config.ts

import { getViteConfig } from "astro/config";

export default getViteConfig({
    test: {
        environment: "jsdom",
        setupFiles: ["./setupTests.ts"],
        globals: true,
        coverage: {
            provider: "v8",
            reporter: ["text", "html"],
            exclude: [
                "**/*.stories.*",
            ],
        },
    },
});

tests.ts - helper file for tests with client components

import { getContainerRenderer as reactRenderer } from "@astrojs/react";
import { experimental_AstroContainer } from "astro/container";
import { loadRenderers } from "astro:container";

/**
 * Creates a test container for Astro components.
 * React renderer is important otherwise the tests fail when client-side components are used.
 */
export async function createTestContainer() {
    const renderers = await loadRenderers([reactRenderer()]);
    return await experimental_AstroContainer.create({ renderers });
}

*.test.ts - example test

// @vitest-environment node
import { createTestContainer } from "@/lib/tests";
import { expect, test } from "vitest";
import TabbedPageLayout from "./TabbedPageLayout.astro";

test("TabbedPageLayout renders with basic structure", async () => {
    const container = await createTestContainer();

    const result = await container.renderToString(TabbedPageLayout, {
        props: {
            // ...
        },
        slots: {
            // ...
        },
    });

    expect(result).toContain("Test Page");
});

Parting Thoughts

I'm usually reluctant to write up things like this. Had I started with a fresh project, I would have avoided most of these frustrations. But some errors weren't obvious at first glance, and we're not always working on shiny new projects where starter kits wire everything up. Sometimes you have to add to whatever's already there and deal with half-correct config. Sometimes you hope the vibe-coded app has sensible defaults, but alas it did not 😂