Skip to content

Commit

Permalink
chore: add more tests
Browse files Browse the repository at this point in the history
  • Loading branch information
stefanmaric committed Sep 3, 2024
1 parent e3bcac0 commit 9509b6f
Show file tree
Hide file tree
Showing 7 changed files with 407 additions and 24 deletions.
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
116 changes: 99 additions & 17 deletions src/core.test.ts
Original file line number Diff line number Diff line change
@@ -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<RetryPolicy>((state, next) => {
if (state.attempt > 2) {
throw new Error('Max attempts reached')
}
return next ? next(state) : 0
})
const policy2 = vi.fn<RetryPolicy>((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<RetryPolicy>(join(vi.fn(), vi.fn()))
assertType<RetryPolicy>(join(vi.fn(), [vi.fn()]))
assertType<RetryPolicy>(join(vi.fn(), [vi.fn(), vi.fn()]))
assertType<RetryPolicy>(join([vi.fn()], [vi.fn(), vi.fn()]))
})

it('should provide inference to inline policies', () => {
const policy = join(
(state, next) => {
assertType<RetryState>(state)
assertType<RetryPolicy | undefined>(next)

return next ? next(state) : 0
},
(state) => {
assertType<RetryState>(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<RetryPolicy>(policy)
})
})
48 changes: 48 additions & 0 deletions src/policies/Backoff.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
55 changes: 55 additions & 0 deletions src/policies/BrandError.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
65 changes: 65 additions & 0 deletions src/policies/JItter.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading

0 comments on commit 9509b6f

Please sign in to comment.