add cache testing utilities

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-05-22 14:43:20 +02:00
parent 02103becc6
commit 805cbe7970
18 changed files with 678 additions and 42 deletions
+23
View File
@@ -25,6 +25,7 @@ import { HttpRouterFactoryOptions } from '@backstage/backend-app-api';
import { HttpRouterService } from '@backstage/backend-plugin-api';
import { IdentityService } from '@backstage/backend-plugin-api';
import { JsonObject } from '@backstage/types';
import Keyv from 'keyv';
import { Knex } from 'knex';
import { LifecycleService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
@@ -423,6 +424,28 @@ export interface TestBackendOptions<TExtensionPoints extends any[]> {
>;
}
// @public
export type TestCacheId = 'MEMORY' | 'REDIS_7' | 'MEMCACHED_1';
// @public
export class TestCaches {
static create(options?: {
ids?: TestCacheId[];
disableDocker?: boolean;
}): TestCaches;
// (undocumented)
eachSupportedId(): [TestCacheId][];
init(id: TestCacheId): Promise<{
store: string;
connection: string;
keyv: Keyv;
}>;
// (undocumented)
static setDefaults(options: { ids?: TestCacheId[] }): void;
// (undocumented)
supports(id: TestCacheId): boolean;
}
// @public
export type TestDatabaseId =
| 'POSTGRES_16'
+4
View File
@@ -53,10 +53,14 @@
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@backstage/types": "workspace:^",
"@keyv/memcache": "^1.3.5",
"@keyv/redis": "^2.5.3",
"@types/keyv": "^4.2.0",
"better-sqlite3": "^9.0.0",
"cookie": "^0.6.0",
"express": "^4.17.1",
"fs-extra": "^11.0.0",
"keyv": "^4.5.2",
"knex": "^3.0.0",
"msw": "^1.0.0",
"mysql2": "^3.0.0",
@@ -0,0 +1,53 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isDockerDisabledForTests } from '../util';
import { TestCaches } from './TestCaches';
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
jest.setTimeout(60_000);
describe('TestCaches', () => {
const caches = TestCaches.create();
it.each(caches.eachSupportedId())('fires up a cache, %p', async cacheId => {
const { keyv } = await caches.init(cacheId);
await keyv.set('test', 'value');
await expect(keyv.get('test')).resolves.toBe('value');
});
itIfDocker('clears between tests, part 1', async () => {
const { keyv } = await caches.init('REDIS_7');
// eslint-disable-next-line jest/no-standalone-expect
await expect(keyv.get('collision')).resolves.toBeUndefined();
await keyv.set('collision', 'something');
});
itIfDocker('clears between tests, part 2', async () => {
const { keyv } = await caches.init('REDIS_7');
// eslint-disable-next-line jest/no-standalone-expect
await expect(keyv.get('collision')).resolves.toBeUndefined();
await keyv.set('collision', 'something');
});
itIfDocker('clears between tests, part 3', async () => {
const { keyv } = await caches.init('REDIS_7');
// eslint-disable-next-line jest/no-standalone-expect
await expect(keyv.get('collision')).resolves.toBeUndefined();
await keyv.set('collision', 'something');
});
});
+210
View File
@@ -0,0 +1,210 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Keyv from 'keyv';
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
import { connectToExternalMemcache, startMemcachedContainer } from './memcache';
import { connectToExternalRedis, startRedisContainer } from './redis';
import { Instance, TestCacheId, TestCacheProperties, allCaches } from './types';
/**
* Encapsulates the creation of ephemeral test cache instances for use inside
* unit or integration tests.
*
* @public
*/
export class TestCaches {
private readonly instanceById: Map<string, Instance>;
private readonly supportedIds: TestCacheId[];
private static defaultIds?: TestCacheId[];
/**
* Creates an empty `TestCaches` instance, and sets up Jest to clean up all of
* its acquired resources after all tests finish.
*
* You typically want to create just a single instance like this at the top of
* your test file or `describe` block, and then call `init` many times on that
* instance inside the individual tests. Spinning up a "physical" cache
* instance takes a considerable amount of time, slowing down tests. But
* wiping the contents of an instance using `init` is very fast.
*/
static create(options?: {
ids?: TestCacheId[];
disableDocker?: boolean;
}): TestCaches {
const ids = options?.ids;
const disableDocker = options?.disableDocker ?? isDockerDisabledForTests();
let testCacheIds: TestCacheId[];
if (ids) {
testCacheIds = ids;
} else if (TestCaches.defaultIds) {
testCacheIds = TestCaches.defaultIds;
} else {
testCacheIds = Object.keys(allCaches) as TestCacheId[];
}
const supportedIds = testCacheIds.filter(id => {
const properties = allCaches[id];
if (!properties) {
return false;
}
// If the caller has set up the env with an explicit connection string,
// we'll assume that this target will work
if (
properties.connectionStringEnvironmentVariableName &&
process.env[properties.connectionStringEnvironmentVariableName]
) {
return true;
}
// If the cache doesn't require docker at all, there's nothing to worry
// about
if (!properties.dockerImageName) {
return true;
}
// If the cache requires docker, but docker is disabled, we will fail.
if (disableDocker) {
return false;
}
return true;
});
const caches = new TestCaches(supportedIds);
if (supportedIds.length > 0) {
afterAll(async () => {
await caches.shutdown();
});
}
return caches;
}
static setDefaults(options: { ids?: TestCacheId[] }) {
TestCaches.defaultIds = options.ids;
}
private constructor(supportedIds: TestCacheId[]) {
this.instanceById = new Map();
this.supportedIds = supportedIds;
}
supports(id: TestCacheId): boolean {
return this.supportedIds.includes(id);
}
eachSupportedId(): [TestCacheId][] {
return this.supportedIds.map(id => [id]);
}
/**
* Returns a fresh, empty cache for the given driver.
*
* @param id - The ID of the cache to use, e.g. 'REDIS_7'
* @returns Cache connection properties
*/
async init(
id: TestCacheId,
): Promise<{ store: string; connection: string; keyv: Keyv }> {
const properties = allCaches[id];
if (!properties) {
const candidates = Object.keys(allCaches).join(', ');
throw new Error(
`Unknown test cache ${id}, possible values are ${candidates}`,
);
}
if (!this.supportedIds.includes(id)) {
const candidates = this.supportedIds.join(', ');
throw new Error(
`Unsupported test cache ${id} for this environment, possible values are ${candidates}`,
);
}
// Ensure that a testcontainers instance is up for this ID
let instance: Instance | undefined = this.instanceById.get(id);
if (!instance) {
instance = await this.initAny(properties);
this.instanceById.set(id, instance);
}
// Ensure that it's cleared of data from previous tests
await instance.keyv.clear();
return {
store: instance.store,
connection: instance.connection,
keyv: instance.keyv,
};
}
private async initAny(properties: TestCacheProperties): Promise<Instance> {
switch (properties.store) {
case 'memcache':
return this.initMemcached(properties);
case 'redis':
return this.initRedis(properties);
case 'memory':
return {
store: 'memory',
connection: 'memory',
keyv: new Keyv(),
stop: async () => {},
};
default:
throw new Error(`Unknown cache store '${properties.store}'`);
}
}
private async initMemcached(
properties: TestCacheProperties,
): Promise<Instance> {
// Use the connection string if provided
const envVarName = properties.connectionStringEnvironmentVariableName;
if (envVarName) {
const connectionString = process.env[envVarName];
if (connectionString) {
return connectToExternalMemcache(connectionString);
}
}
return await startMemcachedContainer(properties.dockerImageName!);
}
private async initRedis(properties: TestCacheProperties): Promise<Instance> {
// Use the connection string if provided
const envVarName = properties.connectionStringEnvironmentVariableName;
if (envVarName) {
const connectionString = process.env[envVarName];
if (connectionString) {
return connectToExternalRedis(connectionString);
}
}
return await startRedisContainer(properties.dockerImageName!);
}
private async shutdown() {
const instances = [...this.instanceById.values()];
this.instanceById.clear();
await Promise.all(
instances.map(({ stop }) =>
stop().catch(error => {
console.warn(`TestCaches: Failed to stop container`, { error });
}),
),
);
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TestCaches } from './TestCaches';
export type { TestCacheId } from './types';
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
import { startMemcachedContainer } from './memcache';
import { v4 as uuid } from 'uuid';
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
jest.setTimeout(60_000);
describe('startMemcachedContainer', () => {
itIfDocker('successfully launches the container', async () => {
const { stop, keyv } = await startMemcachedContainer('memcached:1');
const value = uuid();
await keyv.set('test', value);
// eslint-disable-next-line jest/no-standalone-expect
await expect(keyv.get('test')).resolves.toBe(value);
await stop();
});
});
+83
View File
@@ -0,0 +1,83 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Keyv from 'keyv';
import KeyvMemcache from '@keyv/memcache';
import { v4 as uuid } from 'uuid';
import { Instance } from './types';
async function attemptMemcachedConnection(connection: string): Promise<Keyv> {
const startTime = Date.now();
for (;;) {
try {
const store = new KeyvMemcache(connection);
const keyv = new Keyv({ store });
const value = uuid();
await keyv.set('test', value);
if ((await keyv.get('test')) === value) {
return keyv;
}
} catch (e) {
if (Date.now() - startTime > 30_000) {
throw new Error(
`Timed out waiting for memcached to be ready for connections, ${e}`,
);
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
export async function connectToExternalMemcache(
connection: string,
): Promise<Instance> {
const keyv = await attemptMemcachedConnection(connection);
return {
store: 'memcache',
connection,
keyv,
stop: async () => await keyv.disconnect(),
};
}
export async function startMemcachedContainer(
image: string,
): Promise<Instance> {
// Lazy-load to avoid side-effect of importing testcontainers
const { GenericContainer } = await import('testcontainers');
const container = await new GenericContainer(image)
.withExposedPorts(11211)
.start();
const host = container.getHost();
const port = container.getMappedPort(11211);
const connection = `${host}:${port}`;
const keyv = await attemptMemcachedConnection(connection);
return {
store: 'memcache',
connection,
keyv,
stop: async () => {
await keyv.disconnect();
await container.stop({ timeout: 10_000 });
},
};
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
import { startRedisContainer } from './redis';
import { v4 as uuid } from 'uuid';
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
jest.setTimeout(60_000);
describe('startRedisContainer', () => {
itIfDocker('successfully launches the container', async () => {
const { stop, keyv } = await startRedisContainer('redis:7');
const value = uuid();
await keyv.set('test', value);
// eslint-disable-next-line jest/no-standalone-expect
await expect(keyv.get('test')).resolves.toBe(value);
await stop();
});
});
+81
View File
@@ -0,0 +1,81 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';
import { v4 as uuid } from 'uuid';
import { Instance } from './types';
async function attemptRedisConnection(connection: string): Promise<Keyv> {
const startTime = Date.now();
for (;;) {
try {
const store = new KeyvRedis(connection);
const keyv = new Keyv({ store });
const value = uuid();
await keyv.set('test', value);
if ((await keyv.get('test')) === value) {
return keyv;
}
} catch (e) {
if (Date.now() - startTime > 30_000) {
throw new Error(
`Timed out waiting for redis to be ready for connections, ${e}`,
);
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
export async function connectToExternalRedis(
connection: string,
): Promise<Instance> {
const keyv = await attemptRedisConnection(connection);
return {
store: 'redis',
connection,
keyv,
stop: async () => await keyv.disconnect(),
};
}
export async function startRedisContainer(image: string): Promise<Instance> {
// Lazy-load to avoid side-effect of importing testcontainers
const { GenericContainer } = await import('testcontainers');
const container = await new GenericContainer(image)
.withExposedPorts(6379)
.start();
const host = container.getHost();
const port = container.getMappedPort(6379);
const connection = `redis://${host}:${port}`;
const keyv = await attemptRedisConnection(connection);
return {
store: 'redis',
connection,
keyv,
stop: async () => {
await keyv.disconnect();
await container.stop({ timeout: 10_000 });
},
};
}
+61
View File
@@ -0,0 +1,61 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Keyv from 'keyv';
import { getDockerImageForName } from '../util/getDockerImageForName';
/**
* The possible caches to test against.
*
* @public
*/
export type TestCacheId = 'MEMORY' | 'REDIS_7' | 'MEMCACHED_1';
export type TestCacheProperties = {
name: string;
store: string;
dockerImageName?: string;
connectionStringEnvironmentVariableName?: string;
};
export type Instance = {
store: string;
connection: string;
keyv: Keyv;
stop: () => Promise<void>;
};
export const allCaches: Record<TestCacheId, TestCacheProperties> =
Object.freeze({
REDIS_7: {
name: 'Redis 7.x',
store: 'redis',
dockerImageName: getDockerImageForName('redis:7'),
connectionStringEnvironmentVariableName:
'BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING',
},
MEMCACHED_1: {
name: 'Memcached 1.x',
store: 'memcache',
dockerImageName: getDockerImageForName('memcached:1'),
connectionStringEnvironmentVariableName:
'BACKSTAGE_TEST_CACHE_MEMCACHED1_CONNECTION_STRING',
},
MEMORY: {
name: 'In-memory',
store: 'memory',
},
});
@@ -14,6 +14,5 @@
* limitations under the License.
*/
export { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
export { TestDatabases } from './TestDatabases';
export type { TestDatabaseId } from './types';
+1
View File
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
export * from './cache';
export * from './database';
export * from './msw';
export * from './filesystem';
@@ -20,7 +20,7 @@ export function isDockerDisabledForTests() {
// the (relatively heavy, long running) docker based tests. If you want to
// still run local tests for all databases, just pass either the CI=1 env
// parameter to your test runner, or individual connection strings per
// database.
// database or cache.
return (
Boolean(process.env.BACKSTAGE_TEST_DISABLE_DOCKER) ||
!Boolean(process.env.CI)