Testing Svelte reactivity with a wrapper
In an open-source project that I’m co-developing with Clare Macrae, we had to TDD some functionality in a Svelte component, but first we had to cover the behaviour of the existing component with tests looking like “The user has done this, test that the component has behaved like that”.
The problem in that component was the Svelte reactivity - it is a date editor and it has to “return” a value processed internally. For example, when the text in the input is tm
, the component has to autocomplete this to tomorrow
. We obviously could cover with tests just the reactive part ($: {...}
), but that is just a part of the component’s behaviour. Also when the whole behaviour is covered by tests we can start TDDing new behaviour with new tests.
Let’s consider a Svelte component as below and call it DateEditor
. This is a stripped version of our actual DateEditor
.
<script lang="ts">
import { doAutocomplete, parseTypedDateForDisplayUsingFutureDate } from '../somewhere';
// This is just an input value
export let id: 'start' | 'scheduled' | 'due' | 'done' | 'created' | 'cancelled';
// This is the value updated by the reactive part and read from the outside
export let date: string;
export let forwardOnly: boolean;
let parsedDate: string;
$: {
date = doAutocomplete(date);
parsedDate = parseTypedDateForDisplayUsingFutureDate(id, date, forwardOnly);
}
</script>
<input
bind:value={date}
{id}
type="text"
/>
<!-- The behaviour of this part is not trivial as it is reactive -->
<code>{@html parsedDate}</code>
Simulating user typing something in the input is quite straightforward, but the problem was reading the export let date: string
from the outside. So I created a wrapper component with a variable (dateFromDateEditor
) set by the DateEditor
and bound to an <input>
in the wrapper:
<script lang="ts">
import DateEditor from '../../src/ui/DateEditor.svelte';
let dateFromDateEditor: string = '';
</script>
<DateEditor
id="due"
bind:date={dateFromDateEditor}
forwardOnly={true}
/>
<input bind:value={dateFromDateEditor} id="dueDateFromDateEditor" />
Now we could render the wrapper in tests and test its initial value by checking the value in the wrapper’s input:
function renderDateEditorWrapper() {
const { container } = render(DateEditorWrapper, {});
return container;
}
function getAndCheckRenderedElement<T>(container: HTMLElement, elementId: string) {
const element = container.ownerDocument.getElementById(elementId) as T;
expect(() => element).toBeTruthy();
return element;
}
describe('date editor tests', () => {
it('should test initial state', async () => {
const container = renderDateEditorWrapper();
const dueDateFromDateEditorInput = getAndCheckRenderedElement<HTMLInputElement>(
container,
'dueDateFromDateEditor',
);
expect(dueDateFromDateEditorInput.value).toEqual('');
});
});
Now the next test would be to have the user input something in DateEditor
’s <input>
and see the outcome using our wrapper:
it('should replace an empty date field with typed abbreviation', async () => {
const container = renderDateEditorWrapper();
const dueDateInput = getAndCheckRenderedElement<HTMLInputElement>(container, 'due');
await fireEvent.input(dueDateInput, { target: { value: 'tm ' } });
// Note that despite the typed text being 'tm ', the value in the input
// is transfromed by the reactivity. This is what we have to test!
expect(dueDateInput.value).toEqual('tomorrow');
const dueDateFromDateEditorInput = getAndCheckRenderedElement<HTMLInputElement>(
container,
'dueDateFromDateEditor',
);
expect(dueDateFromDateEditorInput.value).toEqual('tomorrow');
});
So here we have in the last expect()
our reactive value under tests. The only thing do now would be to refactor our 2 tests by extracting a tester function to show the intent and make it readable in a month:
async function userTyped(container: HTMLElement, elementId: string, text: string) {
const input = getAndCheckRenderedElement<HTMLInputElement>(container, elementId);
await fireEvent.input(input, { target: { value: text } });
}
function testInputValue(container: HTMLElement, inputId: string, expectedText: string) {
const input = getAndCheckRenderedElement<HTMLInputElement>(container, inputId);
expect(input.value).toEqual(expectedText);
}
describe('date editor tests', () => {
it('should test initial state', async () => {
const container = renderDateEditorWrapper();
testInputValue(container, 'dueDateFromDateEditor', '');
});
it('should replace an empty date field with typed abbreviation', async () => {
const container = renderDateEditorWrapper();
await userTyped(container, 'due', 'tm ');
testInputValue(container, 'due', 'tomorrow');
testInputValue(container, 'dueDateFromDateEditor', 'tomorrow');
});
});
This is much better - we have expressive tests covering the behaviour of the export let date: string;
variable in the component. But the reactive part changes also the parsedDate
:
<script lang="ts">
// <...>
let parsedDate: string;
$: {
date = doAutocomplete(date);
parsedDate = parseTypedDateForDisplayUsingFutureDate(id, date, forwardOnly);
}
</script>
<!-- <...> -->
<code>{@html parsedDate}</code>
To test this I proposed to make the parsedDate
variable export
‘ed with a default value for real/prod use:
<script lang="ts">
// Use this for testing purposes only
export let parsedDate: string = '';
// <...>
</script>
And now we can use it in the wrapper in the same manner as previously - by binding it to an <input>
element:
<script lang="ts">
import DateEditor from '../../src/ui/DateEditor.svelte';
let dateFromDateEditor: string = '';
let parsedDateFromDateEditor: string = '';
</script>
<DateEditor
id="due"
bind:date={dateFromDateEditor}
bind:parsedDate={parsedDateFromDateEditor}
/>
<input bind:value={dateFromDateEditor} id="dueDateFromDateEditor" />
<input bind:value={parsedDateFromDateEditor} id="parsedDateFromDateEditor" />
In the tests we can reuse our tester function with the id of the new <input>
and we have a pretty solid testing pattern:
describe('date editor tests', () => {
// <...>
it('should replace an empty date field with typed abbreviation', async () => {
const container = renderDateEditorWrapper();
await userTyped(container, 'due', 'tm ');
testInputValue(container, 'due', 'tomorrow');
// Let's imagine that tomorrow is 2024-10-01
testInputValue(container, 'parsedDateFromDateEditor', '2024-10-01');
testInputValue(container, 'dueDateFromDateEditor', 'tomorrow');
});
});
At this point, the only thing left to do is to develop various test cases using this test pattern to cover the full behaviour shown by the component (And not by just the functions in the reactive part). Doing that would require to copy-paste the 9 lines from the previous snippet to another function was extracted:
async function testTypingInput(
{
userTyped,
// Left and right here is due to the layout of the components
expectedLeftText,
expectedRightText,
expectedReturnedDate,
}: {
userTyped: string;
expectedLeftText: string;
expectedRightText: string;
expectedReturnedDate: string;
},
) {
const container = renderDateEditorWrapper({ forwardOnly });
await userTyped(container, 'due', 'tm ');
testInputValue(container, 'due', 'tomorrow');
testInputValue(container, 'parsedDateFromDateEditor', '2024-10-01');
testInputValue(container, 'dueDateFromDateEditor', 'tomorrow');
}
describe('date editor tests', () => {
// <...>
it('should replace an empty date field with typed abbreviation', async () => {
await testTypingInput({
userTyped: 'tm ',
expectedLeftText: 'tomorrow',
expectedRightText: '2024-10-01',
expectedReturnedDate: 'tomorrow',
});
});
});
Finally we were ready to cover all the test cases by injecting errors in the code and seeing if any tests were to fail. And while doing that we noticed that despite injecting the error in forwardOnly
variable no tests were failing, which meant that some part of the behaviour was not covered by tests:
<script lang="ts">
// <...>
$: {
date = doAutocomplete(date);
parsedDate = parseTypedDateForDisplayUsingFutureDate(id, date, /* error injection here: !forwardOnly */ forwardOnly);
}
</script>
To solve this, we had to add an export
‘ed variable to the wrapper and pass it from the tests:
<script lang="ts">
import DateEditor from '../../src/ui/DateEditor.svelte';
// This one
export let forwardOnly: boolean;
let dateFromDateEditor: string = '';
let parsedDateFromDateEditor: string = '';
</script>
But to have it in the tests we shall use the component options when rendering, so here is the updated rendering function and the testing function:
function renderDateEditorWrapper(componentOptions: { forwardOnly: boolean }) {
const { container } = render(DateEditorWrapper, componentOptions);
return container;
}
async function testTypingInput(
{
userTyped,
expectedLeftText,
expectedRightText,
expectedReturnedDate,
}: {
userTyped: string;
expectedLeftText: string;
expectedRightText: string;
expectedReturnedDate: string;
},
{ forwardOnly }: { forwardOnly: boolean } = { forwardOnly: true },
) {
const container = renderDateEditorWrapper({ forwardOnly });
// <...>
}
Note that the default value is taken from the previous version of the wrapper and is declared only once - in the testing function, not the renderer. The field forwardOnly
was introduced as a separate parameter to separate concerns (What user interaction and configuration of the component). Now adding a test with forwardOnly: false
was pretty simple:
describe('date editor tests', () => {
// <...>
it('should select a forward date', async () => {
await testTypingInput({
userTyped: 'friday',
expectedLeftText: 'friday',
expectedRightText: '2024-10-04',
expectedReturnedDate: 'friday',
},
{
// This is explicitly writted to this test to explicitly show the difference between the two
forwardOnly: true
});
});
it('should select a backward/earlier date', async () => {
await testTypingInput({
userTyped: 'friday',
expectedLeftText: 'friday',
expectedRightText: '2024-09-27',
expectedReturnedDate: 'friday',
},
{
forwardOnly: false
});
});
});
This has been done by pair programming in a bit more that 1 hour and a half. Following that, TDDing new features is - we have a “framework” to create tests and have quick feedback about the component’s behaviour under tests. Developing new behaviour is the next step.
What could be improved in this code? Obviously having dedicated matchers in Jest instead of test functions is much better in terms of developer experience, but the latter is just faster, so matchers could be a future improvement.
You may find the full scope of changes in this PR: refactor: test `DateEditor` svelte component by ilandikov · Pull Request #3164 · obsidian-tasks-group/obsidian-tasks · GitHub.