From 9509b6f4fca23cfa89e8637da49dd98be87c26be Mon Sep 17 00:00:00 2001 From: Stefan Maric Date: Tue, 3 Sep 2024 15:31:28 +0200 Subject: [PATCH] chore: add more tests --- README.md | 6 -- src/core.test.ts | 116 ++++++++++++++++++++++---- src/policies/Backoff.test.ts | 48 +++++++++++ src/policies/BrandError.test.ts | 55 +++++++++++++ src/policies/JItter.test.ts | 65 +++++++++++++++ src/retryyy.test.ts | 139 ++++++++++++++++++++++++++++++++ vitest.config.ts | 2 +- 7 files changed, 407 insertions(+), 24 deletions(-) create mode 100644 src/policies/Backoff.test.ts create mode 100644 src/policies/BrandError.test.ts create mode 100644 src/policies/JItter.test.ts create mode 100644 src/retryyy.test.ts diff --git a/README.md b/README.md index 41fafdb..634d74e 100644 --- a/README.md +++ b/README.md @@ -484,12 +484,6 @@ export const retry = (fn, policy) => { Such small function is pretty much the entirety of `retryyy`'s [core implementation](./src/core.ts). -## TODO - -Before v1.0 is released, the following items need to be addressed: - -- [ ] Tests. - ## Contributing Please refer to [CONTRIBUTING.md](./.github/CONTRIBUTING.md). diff --git a/src/core.test.ts b/src/core.test.ts index 0ac3f1e..8e0933d 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -1,24 +1,106 @@ -import { expect, test } from 'vitest' +import { assertType, describe, expect, it, vi } from 'vitest' -import { core } from './core.js' +import type { RetryPolicy, RetryState } from './core.js' +import { core, join } from './core.js' -test('spy function called two times', async () => { - let calls = 0 +describe('core', () => { + it('should call the input function several times', async () => { + let calls = 0 - const wrapped = core( - () => { - calls += 1 - throw new Error('failure') - }, - (state) => { - if (state.attempt >= 3) { - throw state.error + const wrapped = core( + () => { + calls += 1 + throw new Error('failure') + }, + (state) => { + if (state.attempt >= 3) { + throw state.error + } + + return 0 + }, + ) + + await expect(wrapped()).rejects.toThrow('failure') + expect(calls).toBe(3) + }) +}) + +describe('join', () => { + it('should combine multiple retry policies', () => { + const policy1 = vi.fn((state, next) => { + if (state.attempt > 2) { + throw new Error('Max attempts reached') } + return next ? next(state) : 0 + }) + const policy2 = vi.fn((state) => state.attempt * 1000) + const combinedPolicy = join(policy1, policy2) + + const state = { + attempt: 1, + } as RetryState + + expect(combinedPolicy(state)).toBe(1000) + expect(policy1).toHaveBeenCalledWith(state, policy2) + expect(policy2).toHaveBeenCalledWith(state) + + state.attempt = 2 + expect(combinedPolicy(state)).toBe(2000) + + state.attempt = 3 + expect(() => combinedPolicy(state)).toThrow('Max attempts reached') + }) + + it('should flatten nested arrays of policies', () => { + const policy1: RetryPolicy = () => 100 + const policy2: RetryPolicy = () => 200 + const policy3: RetryPolicy = () => 300 + + let combinedPolicy = join([policy1, policy2], policy3) + expect(combinedPolicy({} as RetryState)).toBe(100) + + combinedPolicy = join(policy1, [policy2, policy3]) + expect(combinedPolicy({} as RetryState)).toBe(100) + + combinedPolicy = join(policy1, [policy2], policy3) + expect(combinedPolicy({} as RetryState)).toBe(100) + }) + + it('should force at least 2 policies at the type-level', () => { + expect(() => { + // @ts-expect-error 2345 must pass at least 2 policies + join() + // @ts-expect-error 2345 must pass at least 2 policies + join(vi.fn()) + // @ts-expect-error 2345 must pass at least 2 policies + join([vi.fn()]) + }).toThrow() + + // While all these should be fine. + assertType(join(vi.fn(), vi.fn())) + assertType(join(vi.fn(), [vi.fn()])) + assertType(join(vi.fn(), [vi.fn(), vi.fn()])) + assertType(join([vi.fn()], [vi.fn(), vi.fn()])) + }) + + it('should provide inference to inline policies', () => { + const policy = join( + (state, next) => { + assertType(state) + assertType(next) + + return next ? next(state) : 0 + }, + (state) => { + assertType(state) + + return state.attempt * 1000 + }, + ) - return 0 - }, - ) + expect(policy({ attempt: 1 } as RetryState)).toBe(1000) - await expect(wrapped()).rejects.toThrow('failure') - expect(calls).toBe(3) + assertType(policy) + }) }) diff --git a/src/policies/Backoff.test.ts b/src/policies/Backoff.test.ts new file mode 100644 index 0000000..10bceb0 --- /dev/null +++ b/src/policies/Backoff.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest' +import { Backoff } from './Backoff.js' +import type { RetryState } from '../core.js' + +describe('Backoff', () => { + it('should use default values when no options are provided', () => { + const backoff = Backoff() + + expect(backoff({ attempt: 1 } as RetryState)).toBe(150) + expect(backoff({ attempt: 2 } as RetryState)).toBe(300) + expect(backoff({ attempt: 3 } as RetryState)).toBe(600) + }) + + it('should respect custom delay and exp options', () => { + const backoff = Backoff({ delay: 100, exp: 3 }) + + expect(backoff({ attempt: 1 } as RetryState)).toBe(100) + expect(backoff({ attempt: 2 } as RetryState)).toBe(300) + expect(backoff({ attempt: 3 } as RetryState)).toBe(900) + }) + + it('should respect the max option', () => { + const backoff = Backoff({ delay: 1000, exp: 2, max: 3000 }) + + expect(backoff({ attempt: 1 } as RetryState)).toBe(1000) + expect(backoff({ attempt: 2 } as RetryState)).toBe(2000) + expect(backoff({ attempt: 3 } as RetryState)).toBe(3000) + expect(backoff({ attempt: 3 } as RetryState)).toBe(3000) + }) + + it('should throw TypeError for invalid delay', () => { + expect(() => Backoff({ delay: -1 })).toThrow(TypeError) + expect(() => Backoff({ delay: 0 })).toThrow(TypeError) + }) + + it('should throw TypeError for invalid exp', () => { + expect(() => Backoff({ exp: -1 })).toThrow(TypeError) + expect(() => Backoff({ exp: 0 })).toThrow(TypeError) + }) + + it('should call the next function if provided', () => { + const backoff = Backoff() + const next = vi.fn() + + backoff({ attempt: 1 } as RetryState, next) + expect(next).toHaveBeenCalledWith({ attempt: 1 } as RetryState) + }) +}) diff --git a/src/policies/BrandError.test.ts b/src/policies/BrandError.test.ts new file mode 100644 index 0000000..0e42e70 --- /dev/null +++ b/src/policies/BrandError.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest' +import { BrandError, RetryyyError } from './BrandError.js' +import type { RetryState } from '../core.js' + +describe('BrandError', () => { + it('should throw RetryyyError when no next function is provided', () => { + const policy = BrandError() + const testError = new Error('Test error') + const state = { + error: testError, + errors: [testError], + } as RetryState + + expect(() => policy(state)).toThrow(RetryyyError) + }) + + it('should call next function if provided', () => { + const policy = BrandError() + const next = vi.fn() + const state = { error: null } as RetryState + + policy(state, next) + expect(next).toHaveBeenCalledWith(state) + }) + + it('should wrap errors in RetryyyError', () => { + const policy = BrandError() + const lastError = new Error('Last error') + const prevError = new Error('Previous error') + const state = { + error: lastError, + errors: [prevError, lastError], + } as RetryState + + expect(() => policy(state)).toThrow(RetryyyError) + try { + policy(state) + } catch (error) { + expect(error).toBeInstanceOf(RetryyyError) + expect(error).toBeInstanceOf(AggregateError) + expect((error as RetryyyError).errors).toHaveLength(2) + expect((error as RetryyyError).errors[0]).toEqual(prevError) + expect((error as RetryyyError).errors[1]).toEqual(lastError) + expect((error as RetryyyError).cause).toEqual(lastError) + } + }) + + it('should throw RetryyyError with correct message', () => { + const policy = BrandError() + const testError = new Error('Test error') + const state = { error: testError, errors: [testError] } as RetryState + + expect(() => policy(state)).toThrow('RetryyyError') + }) +}) diff --git a/src/policies/JItter.test.ts b/src/policies/JItter.test.ts new file mode 100644 index 0000000..9132140 --- /dev/null +++ b/src/policies/JItter.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { + DecorrelatedJitter, + EqualJitter, + FullJitter, + Jitter, +} from './Jitter.js' +import type { RetryState } from '../core.js' + +describe('Jitter', () => { + it('should randomize the delay between retries', () => { + const jitter = Jitter() + const state = { attempt: 1 } as RetryState + const next = () => 100 + + const delay1 = jitter(state, next) + const delay2 = jitter(state, next) + + expect(delay1).not.toBe(delay2) + }) +}) + +describe('FullJitter', () => { + it('should randomize the delay between 0 and the delay', () => { + const jitter = FullJitter() + const state = { attempt: 1 } as RetryState + const next = () => 100 + + const delay = jitter(state, next) + + expect(delay).toBeGreaterThanOrEqual(0) + expect(delay).toBeLessThanOrEqual(100) + }) +}) + +describe('EqualJitter', () => { + it('should randomize the delay between -50% and 50% of the delay', () => { + const jitter = EqualJitter() + const state = { attempt: 1 } as RetryState + const next = () => 100 + + const delay = jitter(state, next) + + expect(delay).toBeGreaterThanOrEqual(50) + expect(delay).toBeLessThanOrEqual(100) + }) +}) + +describe('DecorrelatedJitter', () => { + it('should randomize the delay between -50% and 50% of the delay', () => { + const jitter = DecorrelatedJitter({ initial: 50, max: 1500 }) + const state = { attempt: 1, delay: 0 } as RetryState + + const first = jitter(state) + expect(first).toBeGreaterThanOrEqual(50) + expect(first).toBeLessThanOrEqual(150) + + state.attempt = 2 + state.delay = 100 + + const second = jitter(state) + expect(second).toBeGreaterThanOrEqual(50) + expect(second).toBeLessThanOrEqual(300) + }) +}) diff --git a/src/retryyy.test.ts b/src/retryyy.test.ts new file mode 100644 index 0000000..a2ab6aa --- /dev/null +++ b/src/retryyy.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest' +import { Retryyy, retryyy, type RetryPolicy } from './retryyy.js' + +const TEST_OPTIONS = { + // Short delay and short timeout to make the test fast + initialDelay: 15, + timeout: 350, + // Disable logging so tests don't fail + logError: false, + logWarn: false, +} as const + +describe('retryyy', () => { + it('should retry the operation a few times', async () => { + const fn = vi.fn(() => Promise.reject(new Error('Failure'))) + + await expect(retryyy(fn, TEST_OPTIONS)).rejects.toThrow('Failure') + + expect(fn.mock.calls.length).toBeGreaterThanOrEqual(5) + }) + + it('should use the Default policy when none is provided', async () => { + const DefaultModule = await import('./policies/Default.js') + const defaultSpy = vi.spyOn(DefaultModule, 'Default') + + await retryyy(async () => {}) + + expect(defaultSpy).toHaveBeenCalledTimes(1) + expect(defaultSpy).toHaveBeenCalledWith(undefined) + + defaultSpy.mockRestore() + }) + + it('should pass through options to the Default policy', async () => { + const DefaultModule = await import('./policies/Default.js') + const defaultSpy = vi.spyOn(DefaultModule, 'Default') + const fn = vi.fn(() => Promise.reject(new Error('Failure'))) + + const options = { + ...TEST_OPTIONS, + maxAttempts: 1, + } + + await expect(retryyy(fn, options)).rejects.toThrow('Failure') + + expect(defaultSpy).toHaveBeenCalledTimes(1) + expect(defaultSpy).toHaveBeenCalledWith(options) + + defaultSpy.mockRestore() + }) + + it('should use custom policy if provided', async () => { + const DefaultModule = await import('./policies/Default.js') + const defaultSpy = vi.spyOn(DefaultModule, 'Default') + const fn = vi.fn(() => Promise.reject(new Error('Failure'))) + const policy = vi.fn((state) => { + if (state.error) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw state.error + } + + return 0 + }) + + await expect(retryyy(fn, policy)).rejects.toThrow('Failure') + + expect(defaultSpy).toHaveBeenCalledTimes(0) + expect(policy).toHaveBeenCalledTimes(1) + + defaultSpy.mockRestore() + }) + + it('should retry immediately if fastTrack is true', async () => { + const fn = vi.fn(() => Promise.reject(new Error('Failure'))) + const options = { + ...TEST_OPTIONS, + maxAttempts: 2, + fastTrack: true, + } + + const start = Date.now() + await expect(retryyy(fn, options)).rejects.toThrow('Failure') + const end = Date.now() + + expect(fn.mock.calls.length).toBe(2) + expect(end - start).toBeLessThanOrEqual(2) + }) + + it('should not retry if aborted before first attempt', async () => { + const controller = new AbortController() + const { signal } = controller + // Abort right away + controller.abort(new Error('Aborted')) + + const fn = vi.fn(() => Promise.reject(new Error('Failure'))) + + await expect(retryyy(fn, TEST_OPTIONS, signal)).rejects.toThrow('Aborted') + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should stop retrying if aborted between attempts', async () => { + const controller = new AbortController() + const { signal } = controller + // Abort after a short delay + setTimeout(() => { + controller.abort(new Error('Aborted')) + }, 35) + + const fn = vi.fn(() => Promise.reject(new Error('Failure'))) + + await expect(retryyy(fn, TEST_OPTIONS, signal)).rejects.toThrow('Aborted') + + expect(fn.mock.calls.length).toBeGreaterThan(1) + expect(fn.mock.calls.length).toBeLessThanOrEqual(5) + }) +}) + +describe('Retryyy', () => { + it('should wrap class methods in retry logic', async () => { + // There's no easy way to spy on the intermediary class method before it + // gets wrapped by the decorator, so track manually. + let called = 0 + + class UserModel { + @Retryyy(TEST_OPTIONS) + async fetchUser(id: number) { + called++ + throw new Error('Failure') + return Promise.resolve({ id, name: 'John Doe' }) + } + } + + const model = new UserModel() + + await expect(model.fetchUser(1)).rejects.toThrow('Failure') + expect(called).toBeGreaterThan(1) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 40e7d8d..4016480 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ clearMocks: true, coverage: { all: true, - exclude: ['lib'], + exclude: ['lib', '**/*.test.ts'], include: ['src'], reporter: ['html', 'lcov'], },