Library / SDK
tinylibs/tinyspy avatar
tinylibs/tinyspy

tinyspy: a 10KB spy library for vitest, jest and TypeScript

🕵🏻‍♂️ minimal fork of nanospy, with more features

633 stars37 forksTypeScriptMIT

At a glance

What is it?
Tinyspy is a minimal fork of nanospy that records calls, returns and results on a wrapped function, and adds spyOn for methods, getters and setters, a restore method, and a reset that clears the recorded state. It is written in TypeScript, ships with no dependencies, and is the spy implementation behind vitest.
Who is it for?
tinyspy suits anyone who wants call recording in a test without pulling in a mocking framework, whether in vitest, in jest, or in a project with its own minimal setup, and it suits TypeScript users who want typed spy state rather than a hand-rolled counter. It does not suit a team that expects a full mocking toolkit, since it records and restores calls and nothing more, and it does not suit a project still on a Node version below 14, which the package engines field excludes.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 49 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A 10KB package with no dependencies at all

Tinyspy is described as a 10KB package for minimal and easy testing with no dependencies. It was created for having a tiny spy library to use in vitest, and the README notes it can also be used in jest and other test environments.

That origin story explains the design. Vitest is built by the same ecosystem, and a spy implementation is the kind of thing that gets pulled into a test runner as a dependency, where every kilobyte is paid for by every user of the runner. A 10KB implementation with no dependencies is a deliberate answer to that constraint.

The package lives on npm as tinyspy, the primary language is TypeScript, and the licence is MIT. The repository is under the tinylibs organisation, which also runs an RFC process, and the README asks people who want more libraries of this kind, tinypool for instance, to submit an RFC there. That tells you something about how the maintainers think about the project: it is one member of a family of small, focused packages rather than a standalone product.

The topics tags give the usage context: test, spy, vitest, jest, vite and hacktoberfest.

The audience is therefore narrow and clear. It is for people who want to record that a function was called, with what arguments, and what it returned, and who would rather not take a mocking framework to get that.

called, callCount, calls, returns and results

The simplest usage is a function wrapped by spy, and the recorded state is readable directly off the returned object.

js
const fn = (n) => n + '!'
const spied = spy(fn)

spied('a')

console.log(spied.called) // true
console.log(spied.callCount) // 1
console.log(spied.calls) // [['a']]
console.log(spied.results) // [['ok', 'a!']]
console.log(spied.returns) // ['a!']

The shape of the data is worth reading carefully. called is a boolean, callCount is a number, calls is an array of argument arrays, returns is a flat array of returned values, and results is an array of pairs, each holding a status and the value. In the example the status is ok, which is the marker for a successful call.

The calls array is nested on purpose. Each entry is the list of arguments for one invocation, so a function called twice with two arguments each produces two arrays of two items, and a test can assert on argument order and count without the spy having to guess what a multi-argument call means.

The other thing the example shows is that the spy is transparent. The wrapped function still runs and still returns its value, so a spy here is a recorder rather than a stub, and the code under test behaves exactly as it would without the spy in place.

There is a reset function that clears the recorded state:

js
spied.reset()

console.log(spied.called) // false
console.log(spied.callCount) // 0
console.log(spied.calls) // []
console.log(spied.returns) // []

Resetting calls, returns, called and callCount is what makes a spy reusable across the assertions of a single test without re-wrapping it.

Since 3.0, promises are recorded but not unwrapped

The most consequential change in the library's recent history is a behaviour change, and the README flags it in bold in the middle of the usage section.

Since version 3.0, tinyspy does not unwrap the Promise in returns and results any more, so you have to await it manually.

js
const spied = spy(async (n) => n + '!')

const promise = spied('a')

console.log(spied.called) // true
console.log(spied.results) // ['ok', Promise<'a!'>]

await promise

console.log(spied.results) // ['ok', Promise<'a!'>]

console.log(await spied.returns[0]) // 'a!'

The second console.log after the await prints the same thing as before it, and that is the point. The recorded value is the promise itself, not the value it settles to, and awaiting the promise does not change what was recorded.

There is a warning attached to this. Because the promise is stored as-is, a function that returned a promise will always have a result type of ok even if that promise rejected. A test asserting on results therefore cannot distinguish a resolved call from a rejected one.

The library does keep a separate property for the settled values, resolves, and the README notes that version 3.0 still exposes resolved values there:

js
const spied = spy(async (n) => n + '!')

const promise = spied('a')

console.log(spied.resolves) // [] <- not resolved yet

await promise

console.log(spied.resolves) // ['ok', 'a!']

So a spy user has two views of the same calls, results with the raw promise and status, and resolves with the awaited value once it has settled. Any test that asserts on asynchronous outcomes has to know which of the two it is reading.

spyOn reaches methods, getters and setters

The second part of the API is spyOn, which works on an object's method or on its getter and setter. All the spy methods are available on it, so the recording state described earlier applies unchanged.

Spying on a method is a matter of passing the object, the method name and an optional replacement implementation:

js
let apples = 0
const obj = {
  getApples: () => 13,
}

const spy = spyOn(obj, 'getApples', () => apples)
apples = 1

console.log(obj.getApples()) // prints 1

console.log(spy.called) // true
console.log(spy.returns) // [1]

Getters and setters are handled with a descriptor object rather than a string, which keeps the getter and the setter distinguishable:

js
const spyGetter = spyOn(obj, { getter: 'apples' }, () => fakedApples)
const spySetter = spyOn(obj, { setter: 'apples' }, (count) => {
  fakedApples = count
})

In that example the spy setter receives the value, the real property keeps its own value, and the spied value is held separately, so a test can assert what the setter was asked to write without changing what the object actually holds.

The same mechanism turns a plain attribute into a dynamic getter, which the README shows with an object that has a static apples property of 13 and a spy whose getter returns a variable.

Two restoration paths are documented. The willCall method reassigns a mocked function, and restore puts the original implementation back:

js
const spied = spyOn(obj, 'fn').willCall((n) => n + '.')

obj.fn('a')

console.log(spied.returns) // ['a.']

spied.restore()

obj.fn('a')

console.log(spied.returns) // ['a!']

And in the getter example the README restores by calling restore on the object itself, which is a slightly different call shape and worth knowing about if you are following the examples literally.

Size limits, minified identifiers and a clean publish pipeline

The package.json shows how seriously the size claim is taken, and the tooling around it is more interesting than the feature list.

The build script is tsup --minify-identifiers, and the dev dependencies include the size-limit preset for small libraries along with the size-limit time plugin. The repository root carries a .size-limit.json file, which is where the budget is defined, so the 10KB figure in the README is a checked constraint rather than a marketing number.

The release pipeline is also worth noting. The clean-publish tool is used to stage and verify what gets published, the release script is bumpp package.json --commit --push --tag for versioning, and the changelog is generated with changelogithub. The husky and nano-staged combination is set up for commit hooks, with the prepare script running husky on install, and Prettier handles formatting with a .prettierrc.yaml.

The published artefact is deliberately narrow. The files field lists only dist and optional-types.d.ts, the exports map points at ./dist/index.js with its type declarations, and the package is an ES module with a main and module entry pointing at the same build output. The engines field requires Node 14 or later.

The development stack is vite and vitest, with the test script running vitest --no-isolate, and TypeScript at 5.4. The packageManager field pins pnpm 9.1.1 and the repository carries a pnpm-lock.yaml.

There is also a .clean-publish directory and an AUTHORS file, and the root holds a banner.svg, a .npmrc and a tsup.config.ts.

What the fork adds over nanospy

Tinyspy's own description is a minimal fork of nanospy, with more features, and it is worth being concrete about what that means in practice rather than treating the fork as an implementation detail.

The upstream project it forked is nanospy, and using that directly means you get the recording core without the additions this fork carries. From the README, the additions are the spyOn family for object methods, getters and setters, the restore method to put an original implementation back, the willCall method to reassign a mocked function, the resolves property for settled promise values, the reset that clears the recorded state, and the option to make an attribute behave as a dynamic getter.

That is a substantial list for a 10KB budget, and it explains why vitest depends on this package rather than on nanospy itself.

The difference in approach between the two is therefore one of scope rather than philosophy. Nanospy is the minimal recorder; tinyspy is that recorder plus the object level operations a test framework needs when it wants to intercept a method without replacing the whole module.

For a team choosing between them, the question is concrete. If all your assertions are about whether a plain function was called and with what, the smaller upstream package is enough. If you need to intercept a method on an object, restore it afterwards, or observe a promise's settled value, that is what the fork is for, and the README's examples are the specification of that behaviour.

Two maintainers, a version cadence, and a 4.x line

The Authors section names two people, Mohammad Bagher and Vladimir, and the Sponsors section has a place for each of them, so the project is maintained by a very small team with funding handled through GitHub Sponsors.

The release record shows a 4.x line that is not released on a schedule. v4.0.4 is dated 2025-09-22, and then v4.0.5 and v4.0.6 both land on 2026-09-04, the latter within hours of the former. The last push to the repository was on 2026-09-04 as well, and the package version in package.json is 4.0.6.

So the pattern is a stable major line with bursts of patch releases rather than a steady drip. For a package this small, that is a sensible rhythm: nothing changes for months, then a fix or a feature lands and a couple of patches follow it.

The practical consequence is that you should pin an exact version rather than float on a range. A test dependency that changes behaviour under you between two CI runs is the kind of thing that costs an afternoon, and this package has shipped behaviour changes inside a minor line before, such as the promise unwrapping change in 3.0.

The MIT licence means there is no obligation when you embed it, and the npm package name tinyspy is the identifier to use in a dependency declaration.

Editorial conclusion

tinyspy suits anyone who wants call recording in a test without pulling in a mocking framework, whether in vitest, in jest, or in a project with its own minimal setup, and it suits TypeScript users who want typed spy state rather than a hand-rolled counter. It does not suit a team that expects a full mocking toolkit, since it records and restores calls and nothing more, and it does not suit a project still on a Node version below 14, which the package engines field excludes. Check three things before you adopt it: whether your assertions rely on promise results being unwrapped, because since version 3.0 they are not and the README warns that a rejected promise still records an ok result, whether the size limit you care about is still the 10KB the README advertises, and whether you are happy to depend on a package that versions itself in step with vitest. The last push to the repository was on 2026-09-04 and the newest release is v4.0.6 from the same day.

Frequently asked questions

How do I install tinyspy?

Install it as a dev dependency with npm install -D tinyspy, pnpm install -D tinyspy or yarn install -D tinyspy. The package is published on npm as tinyspy, has no dependencies of its own, and requires Node 14 or later.

What does a tinyspy spy record?

The wrapped function still runs, and the spy records called, callCount, calls as an array of argument arrays, returns as an array of returned values, and results as an array of status and value pairs. The reset function clears all of that recorded state.

Does tinyspy unwrap promises in results?

Not since version 3.0. The promise itself is stored, so you have to await it yourself, and the README warns that a function returning a promise always has a result type of ok even if it rejected. Settled values are exposed separately on the resolves property.

What is the difference between tinyspy and nanospy?

Tinyspy is a minimal fork of nanospy that adds features: spyOn for object methods, getters and setters, restore and willCall for replacing and restoring implementations, the resolves property for settled promise values, a reset for recorded state, and dynamic getters for attributes.

How is the 10KB size of tinyspy enforced?

The repository carries a .size-limit.json file and the dev dependencies include the size-limit preset for small libraries along with the time plugin, and the build script is tsup --minify-identifiers, so the package size is a checked budget in the build rather than a claim in the README.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. tinylibs/tinyspy on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/tinylibs-tinyspy.svg)](https://hysenlabs.com/projects/tinylibs-tinyspy)

Was this useful?