diff --git a/lib/datasource/__snapshots__/index.spec.ts.snap b/lib/datasource/__snapshots__/index.spec.ts.snap
deleted file mode 100644
index 269e3ed3267ddebcc0565ed303e44a124baee401..0000000000000000000000000000000000000000
--- a/lib/datasource/__snapshots__/index.spec.ts.snap
+++ /dev/null
@@ -1,24 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`datasource/index adds changelogUrl 1`] = `
-Object {
-  "changelogUrl": "https://github.com/react-native-community/react-native-releases/blob/master/CHANGELOG.md",
-  "releases": Array [
-    Object {
-      "version": "1.0.0",
-    },
-  ],
-  "sourceUrl": "https://github.com/react-native-community/react-native-releases",
-}
-`;
-
-exports[`datasource/index adds sourceUrl 1`] = `
-Object {
-  "releases": Array [
-    Object {
-      "version": "1.0.0",
-    },
-  ],
-  "sourceUrl": "https://github.com/nodejs/node",
-}
-`;
diff --git a/lib/datasource/index.spec.ts b/lib/datasource/index.spec.ts
index 165247f86fdbb27a7b31a0e31f38406413ce5199..7cc6ada2700e80c92ed3b0c9b431c4b893500456 100644
--- a/lib/datasource/index.spec.ts
+++ b/lib/datasource/index.spec.ts
@@ -1,386 +1,536 @@
 import fs from 'fs-extra';
-import { mockFn } from 'jest-mock-extended';
-import * as httpMock from '../../test/http-mock';
-import { logger, mocked } from '../../test/util';
+import { logger } from '../../test/util';
 import {
   EXTERNAL_HOST_ERROR,
   HOST_DISABLED,
 } from '../constants/error-messages';
 import { ExternalHostError } from '../types/errors/external-host-error';
 import { loadModules } from '../util/modules';
+import datasources from './api';
 import { Datasource } from './datasource';
-import * as datasourceDocker from './docker';
-import { GalaxyDatasource } from './galaxy';
-import * as datasourceGithubTags from './github-tags';
-import * as datasourceMaven from './maven';
-import * as datasourceNpm from './npm';
-import { PackagistDatasource } from './packagist';
-import type { DatasourceApi, GetReleasesConfig } from './types';
-import * as datasource from '.';
-
-jest.mock('./docker');
-jest.mock('./maven');
-jest.mock('./npm');
-
-const packagistDatasourceGetReleasesMock =
-  mockFn<DatasourceApi['getReleases']>();
-
-jest.mock('./packagist', () => {
-  return {
-    __esModule: true,
-    PackagistDatasource: class extends jest.requireActual<
-      typeof import('./packagist')
-    >('./packagist').PackagistDatasource {
-      override getReleases = (_: GetReleasesConfig) =>
-        packagistDatasourceGetReleasesMock(_);
-    },
-  };
-});
+import type { DatasourceApi, GetReleasesConfig, ReleaseResult } from './types';
+import {
+  getDatasourceList,
+  getDatasources,
+  getDigest,
+  getPkgReleases,
+  supportsDigests,
+} from '.';
+
+const datasource = 'dummy';
+const depName = 'package';
+
+type RegistriesMock = Record<string, ReleaseResult | (() => ReleaseResult)>;
+const defaultRegistriesMock: RegistriesMock = {
+  'https://reg1.com': { releases: [{ version: '1.2.3' }] },
+};
 
-const dockerDatasource = mocked(datasourceDocker);
-const mavenDatasource = mocked(datasourceMaven);
-const npmDatasource = mocked(datasourceNpm);
+class DummyDatasource extends Datasource {
+  override defaultRegistryUrls = ['https://reg1.com'];
+
+  constructor(private registriesMock: RegistriesMock = defaultRegistriesMock) {
+    super(datasource);
+  }
+
+  override getReleases({
+    registryUrl,
+  }: GetReleasesConfig): Promise<ReleaseResult | null> {
+    const fn = this.registriesMock[registryUrl];
+    if (typeof fn === 'function') {
+      return Promise.resolve(fn());
+    }
+    return Promise.resolve(fn ?? null);
+  }
+}
+
+jest.mock('./metadata-manual', () => ({
+  manualChangelogUrls: {
+    dummy: {
+      package: 'https://foo.bar/package/CHANGELOG.md',
+    },
+  },
+  manualSourceUrls: {
+    dummy: {
+      package: 'https://foo.bar/package',
+    },
+  },
+}));
 
 describe('datasource/index', () => {
   beforeEach(() => {
     jest.resetAllMocks();
   });
-  it('returns datasources', () => {
-    expect(datasource.getDatasources()).toBeDefined();
-
-    const managerList = fs
-      .readdirSync(__dirname, { withFileTypes: true })
-      .filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith('_'))
-      .map((dirent) => dirent.name)
-      .sort();
-    expect(datasource.getDatasourceList()).toEqual(managerList);
+
+  afterEach(() => {
+    datasources.delete(datasource);
   });
-  it('validates datasource', () => {
-    function validateDatasource(module: DatasourceApi, name: string): boolean {
-      if (!module.getReleases) {
-        return false;
+
+  describe('Validations', () => {
+    it('returns datasources', () => {
+      expect(getDatasources()).toBeDefined();
+
+      const managerList = fs
+        .readdirSync(__dirname, { withFileTypes: true })
+        .filter(
+          (dirent) => dirent.isDirectory() && !dirent.name.startsWith('_')
+        )
+        .map((dirent) => dirent.name)
+        .sort();
+      expect(getDatasourceList()).toEqual(managerList);
+    });
+
+    it('validates datasource', () => {
+      function validateDatasource(
+        module: DatasourceApi,
+        name: string
+      ): boolean {
+        if (!module.getReleases) {
+          return false;
+        }
+        return module.id === name;
       }
-      return module.id === name;
-    }
-    function filterClassBasedDatasources(name: string): boolean {
-      return !(datasource.getDatasources().get(name) instanceof Datasource);
-    }
-    const dss = new Map(datasource.getDatasources());
 
-    for (const ds of dss.values()) {
-      if (ds instanceof Datasource) {
-        dss.delete(ds.id);
+      function filterClassBasedDatasources(name: string): boolean {
+        return !(getDatasources().get(name) instanceof Datasource);
       }
-    }
 
-    const loadedDs = loadModules(
-      __dirname,
-      validateDatasource,
-      filterClassBasedDatasources
-    );
-    expect(Array.from(dss.keys())).toEqual(Object.keys(loadedDs));
+      const dss = new Map(getDatasources());
 
-    for (const dsName of dss.keys()) {
-      const ds = dss.get(dsName);
-      expect(validateDatasource(ds, dsName)).toBeTrue();
-    }
-  });
-  it('returns if digests are supported', () => {
-    expect(datasource.supportsDigests(datasourceGithubTags.id)).toBeTrue();
-  });
-  it('returns null for no datasource', async () => {
-    expect(
-      await datasource.getPkgReleases({
-        datasource: null,
-        depName: 'some/dep',
-      })
-    ).toBeNull();
-  });
-  it('returns null for no lookupName', async () => {
-    expect(
-      await datasource.getPkgReleases({
-        datasource: 'npm',
-        depName: null,
-      })
-    ).toBeNull();
-  });
-  it('returns null for unknown datasource', async () => {
-    expect(
-      await datasource.getPkgReleases({
-        datasource: 'gitbucket',
-        depName: 'some/dep',
-      })
-    ).toBeNull();
-  });
-  it('returns class datasource', async () => {
-    expect(
-      await datasource.getPkgReleases({
-        datasource: 'cdnjs',
-        depName: null,
-      })
-    ).toBeNull();
-  });
-  it('returns getDigest', async () => {
-    expect(
-      await datasource.getDigest({
-        datasource: datasourceDocker.id,
-        depName: 'docker/node',
-      })
-    ).toBeUndefined();
-  });
-  it('adds changelogUrl', async () => {
-    npmDatasource.getReleases.mockResolvedValue({
-      releases: [{ version: '1.0.0' }],
+      for (const ds of dss.values()) {
+        if (ds instanceof Datasource) {
+          dss.delete(ds.id);
+        }
+      }
+
+      const loadedDs = loadModules(
+        __dirname,
+        validateDatasource,
+        filterClassBasedDatasources
+      );
+      expect(Array.from(dss.keys())).toEqual(Object.keys(loadedDs));
+
+      for (const dsName of dss.keys()) {
+        const ds = dss.get(dsName);
+        expect(validateDatasource(ds, dsName)).toBeTrue();
+      }
     });
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceNpm.id,
-      depName: 'react-native',
+
+    it('returns null for null datasource', async () => {
+      expect(
+        await getPkgReleases({
+          datasource: null,
+          depName: 'some/dep',
+        })
+      ).toBeNull();
     });
-    expect(res).toMatchSnapshot({
-      changelogUrl:
-        'https://github.com/react-native-community/react-native-releases/blob/master/CHANGELOG.md',
+
+    it('returns null for no depName', async () => {
+      datasources.set(datasource, new DummyDatasource());
+      expect(
+        await getPkgReleases({
+          datasource: datasource,
+          depName: null,
+        })
+      ).toBeNull();
     });
-  });
-  it('applies extractVersion', async () => {
-    npmDatasource.getReleases.mockResolvedValue({
-      releases: [
-        { version: 'v1.0.0' },
-        { version: 'v1.0.1' },
-        { version: 'v2' },
-      ],
+
+    it('returns null for unknown datasource', async () => {
+      expect(
+        await getPkgReleases({
+          datasource: 'some-unknown-datasource',
+          depName: 'some/dep',
+        })
+      ).toBeNull();
     });
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceNpm.id,
-      depName: 'react-native',
-      extractVersion: '^(?<version>v\\d+\\.\\d+)',
-      versioning: 'loose',
+
+    it('ignores and warns for disabled custom registryUrls', async () => {
+      class TestDatasource extends DummyDatasource {
+        override readonly customRegistrySupport = false;
+      }
+      datasources.set(datasource, new TestDatasource());
+      const registryUrls = ['https://foo.bar'];
+
+      const res = await getPkgReleases({ datasource, depName, registryUrls });
+
+      expect(logger.logger.warn).toHaveBeenCalledWith(
+        { datasource: 'dummy', registryUrls, defaultRegistryUrls: undefined },
+        'Custom registries are not allowed for this datasource and will be ignored'
+      );
+      expect(res).toMatchObject({ releases: [{ version: '1.2.3' }] });
     });
-    expect(res.releases).toHaveLength(1);
-    expect(res.releases[0].version).toBe('v1.0');
   });
-  it('adds sourceUrl', async () => {
-    npmDatasource.getReleases.mockResolvedValue({
-      releases: [{ version: '1.0.0' }],
-    });
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceNpm.id,
-      depName: 'node',
-    });
-    expect(res).toMatchSnapshot({
-      sourceUrl: 'https://github.com/nodejs/node',
+
+  describe('Digest', () => {
+    it('returns if digests are supported', () => {
+      datasources.set(datasource, new DummyDatasource());
+      expect(supportsDigests(datasource)).toBeFalse();
     });
-  });
-  it('ignores and warns for registryUrls', async () => {
-    httpMock
-      .scope('https://galaxy.ansible.com')
-      .get('/api/v1/roles/')
-      .query({ owner__username: 'some', name: 'dep' })
-      .reply(200, {});
-    await datasource.getPkgReleases({
-      datasource: GalaxyDatasource.id,
-      depName: 'some.dep',
-      registryUrls: ['https://google.com/'],
+
+    it('returns value if defined', async () => {
+      class TestDatasource extends DummyDatasource {
+        override getDigest(): Promise<string> {
+          return Promise.resolve('123');
+        }
+      }
+      datasources.set(datasource, new TestDatasource());
+
+      expect(supportsDigests(datasource)).toBeTrue();
+      expect(await getDigest({ datasource, depName })).toBe('123');
     });
-    expect(logger.logger.warn).toHaveBeenCalled();
   });
-  it('warns if multiple registryUrls for registryStrategy=first', async () => {
-    dockerDatasource.getReleases.mockResolvedValue(null);
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceDocker.id,
-      depName: 'something',
-      registryUrls: ['https://docker.com', 'https://docker.io'],
+
+  describe('Metadata', () => {
+    beforeEach(() => {
+      datasources.set(datasource, new DummyDatasource());
     });
-    expect(res).toBeNull();
-  });
-  it('hunts registries and returns success', async () => {
-    packagistDatasourceGetReleasesMock
-      .mockResolvedValueOnce(null)
-      .mockResolvedValueOnce({
-        releases: [{ version: '1.0.0' }],
+
+    it('adds changelogUrl', async () => {
+      expect(await getPkgReleases({ datasource, depName })).toMatchObject({
+        changelogUrl: 'https://foo.bar/package/CHANGELOG.md',
       });
-    const res = await datasource.getPkgReleases({
-      datasource: PackagistDatasource.id,
-      depName: 'something',
-      registryUrls: ['https://reg1.com', 'https://reg2.io'],
-    });
-    expect(res).not.toBeNull();
-  });
-  it('returns null for HOST_DISABLED', async () => {
-    packagistDatasourceGetReleasesMock.mockImplementationOnce(() => {
-      throw new ExternalHostError(new Error(HOST_DISABLED));
-    });
-    expect(
-      await datasource.getPkgReleases({
-        datasource: PackagistDatasource.id,
-        depName: 'something',
-        registryUrls: ['https://reg1.com'],
-      })
-    ).toBeNull();
-  });
-  it('hunts registries and aborts on ExternalHostError', async () => {
-    packagistDatasourceGetReleasesMock.mockRejectedValue(
-      new ExternalHostError(new Error())
-    );
-    await expect(
-      datasource.getPkgReleases({
-        datasource: PackagistDatasource.id,
-        depName: 'something',
-        registryUrls: ['https://reg1.com', 'https://reg2.io'],
-      })
-    ).rejects.toThrow(EXTERNAL_HOST_ERROR);
-  });
-  it('hunts registries and returns null', async () => {
-    packagistDatasourceGetReleasesMock.mockImplementationOnce(() => {
-      throw new Error('a');
-    });
-    packagistDatasourceGetReleasesMock.mockImplementationOnce(() => {
-      throw new Error('b');
-    });
-    expect(
-      await datasource.getPkgReleases({
-        datasource: PackagistDatasource.id,
-        depName: 'something',
-        registryUrls: ['https://reg1.com', 'https://reg2.io'],
-      })
-    ).toBeNull();
-  });
-  it('merges custom defaultRegistryUrls and returns success', async () => {
-    mavenDatasource.getReleases.mockResolvedValueOnce({
-      releases: [{ version: '1.0.0' }, { version: '1.1.0' }],
-    });
-    mavenDatasource.getReleases.mockResolvedValueOnce({
-      releases: [{ version: '1.0.0' }],
-    });
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceMaven.id,
-      depName: 'something',
-      defaultRegistryUrls: ['https://reg1.com', 'https://reg2.io'],
-    });
-    expect(res).toEqual({
-      releases: [
-        {
-          registryUrl: 'https://reg1.com',
-          version: '1.0.0',
-        },
-        {
-          registryUrl: 'https://reg1.com',
-          version: '1.1.0',
-        },
-      ],
-    });
-  });
-  it('ignores custom defaultRegistryUrls if registrUrls are set', async () => {
-    mavenDatasource.getReleases.mockResolvedValueOnce({
-      releases: [{ version: '1.0.0' }, { version: '1.1.0' }],
-    });
-    mavenDatasource.getReleases.mockResolvedValueOnce({
-      releases: [{ version: '1.0.0' }],
     });
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceMaven.id,
-      depName: 'something',
-      defaultRegistryUrls: ['https://reg3.com'],
-      registryUrls: ['https://reg1.com', 'https://reg2.io'],
-    });
-    expect(res).toEqual({
-      releases: [
-        {
-          registryUrl: 'https://reg1.com',
-          version: '1.0.0',
-        },
-        {
-          registryUrl: 'https://reg1.com',
-          version: '1.1.0',
-        },
-      ],
+
+    it('adds sourceUrl', async () => {
+      expect(await getPkgReleases({ datasource, depName })).toMatchObject({
+        sourceUrl: 'https://foo.bar/package',
+      });
     });
   });
-  it('merges registries and returns success', async () => {
-    mavenDatasource.getReleases.mockResolvedValueOnce({
-      releases: [{ version: '1.0.0' }, { version: '1.1.0' }],
-    });
-    mavenDatasource.getReleases.mockResolvedValueOnce({
-      releases: [{ version: '1.0.0' }],
-    });
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceMaven.id,
-      depName: 'something',
-      registryUrls: ['https://reg1.com', 'https://reg2.io'],
+
+  describe('Packages', () => {
+    it('supports defaultRegistryUrls parameter', async () => {
+      const registries: RegistriesMock = {
+        'https://foo.bar': { releases: [{ version: '0.0.1' }] },
+      };
+      datasources.set(datasource, new DummyDatasource(registries));
+
+      const res = await getPkgReleases({
+        datasource,
+        depName,
+        defaultRegistryUrls: ['https://foo.bar'],
+      });
+      expect(res).toMatchObject({ releases: [{ version: '0.0.1' }] });
     });
-    expect(res).toEqual({
-      releases: [
-        {
-          registryUrl: 'https://reg1.com',
-          version: '1.0.0',
-        },
-        {
-          registryUrl: 'https://reg1.com',
-          version: '1.1.0',
+
+    it('applies extractVersion', async () => {
+      const registries: RegistriesMock = {
+        'https://reg1.com': {
+          releases: [{ version: 'v4.3.143' }, { version: 'rc4.3.143' }],
         },
-      ],
-    });
-  });
-  it('merges registries and aborts on ExternalHostError', async () => {
-    mavenDatasource.getReleases.mockImplementationOnce(() => {
-      throw new ExternalHostError(new Error());
-    });
-    await expect(
-      datasource.getPkgReleases({
-        datasource: datasourceMaven.id,
-        depName: 'something',
-        registryUrls: ['https://reg1.com', 'https://reg2.io'],
-      })
-    ).rejects.toThrow(EXTERNAL_HOST_ERROR);
-  });
-  it('merges registries and returns null for error', async () => {
-    mavenDatasource.getReleases.mockImplementationOnce(() => {
-      throw new Error('a');
-    });
-    mavenDatasource.getReleases.mockImplementationOnce(() => {
-      throw new Error('b');
-    });
-    expect(
-      await datasource.getPkgReleases({
-        datasource: datasourceMaven.id,
-        depName: 'something',
-        registryUrls: ['https://reg1.com', 'https://reg2.io'],
-      })
-    ).toBeNull();
-  });
-  it('trims sourceUrl', async () => {
-    npmDatasource.getReleases.mockResolvedValue({
-      sourceUrl: ' https://abc.com',
-      releases: [{ version: '1.0.0' }],
-    });
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceNpm.id,
-      depName: 'abc',
+      };
+      datasources.set(datasource, new DummyDatasource(registries));
+
+      const res = await getPkgReleases({
+        datasource,
+        depName,
+        extractVersion: '^(?<version>v\\d+\\.\\d+)',
+        versioning: 'loose',
+      });
+      expect(res).toMatchObject({ releases: [{ version: 'v4.3' }] });
     });
-    expect(res.sourceUrl).toBe('https://abc.com');
-  });
-  it('massages sourceUrl', async () => {
-    npmDatasource.getReleases.mockResolvedValue({
-      sourceUrl: 'scm:git@github.com:Jasig/cas.git',
-      releases: [{ version: '1.0.0' }],
+
+    it('trims sourceUrl', async () => {
+      datasources.set(
+        datasource,
+        new DummyDatasource({
+          'https://reg1.com': {
+            sourceUrl: '   https://abc.com   ',
+            releases: [{ version: '1.0.0' }],
+          },
+        })
+      );
+      const res = await getPkgReleases({
+        datasource,
+        depName: 'foobar',
+      });
+      expect(res).toMatchObject({ sourceUrl: 'https://abc.com' });
     });
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceNpm.id,
-      depName: 'cas',
+
+    it('massages sourceUrl', async () => {
+      datasources.set(
+        datasource,
+        new DummyDatasource({
+          'https://reg1.com': {
+            sourceUrl: 'scm:git@github.com:Jasig/cas.git',
+            releases: [{ version: '1.0.0' }],
+          },
+        })
+      );
+      const res = await getPkgReleases({
+        datasource,
+        depName: 'foobar',
+      });
+      expect(res).toMatchObject({ sourceUrl: 'https://github.com/Jasig/cas' });
     });
-    expect(res.sourceUrl).toBe('https://github.com/Jasig/cas');
-  });
 
-  it('applies replacements', async () => {
-    npmDatasource.getReleases.mockResolvedValue({
-      releases: [{ version: '1.0.0' }],
+    it('applies replacements', async () => {
+      datasources.set(datasource, new DummyDatasource());
+      const res = await getPkgReleases({
+        datasource,
+        depName,
+        replacementName: 'def',
+        replacementVersion: '2.0.0',
+      });
+      expect(res).toMatchObject({
+        replacementName: 'def',
+        replacementVersion: '2.0.0',
+      });
     });
-    const res = await datasource.getPkgReleases({
-      datasource: datasourceNpm.id,
-      depName: 'abc',
-      replacementName: 'def',
-      replacementVersion: '2.0.0',
+
+    describe('Registry strategies', () => {
+      describe('first', () => {
+        class FirstRegistryDatasource extends DummyDatasource {
+          override readonly registryStrategy = 'first';
+        }
+
+        it('returns value from single registry', async () => {
+          datasources.set(datasource, new FirstRegistryDatasource());
+
+          const res = await getPkgReleases({
+            datasource,
+            depName,
+            registryUrls: ['https://reg1.com'],
+          });
+
+          expect(res).toMatchObject({
+            releases: [{ version: '1.2.3' }],
+            registryUrl: 'https://reg1.com',
+          });
+          expect(logger.logger.warn).not.toHaveBeenCalled();
+        });
+
+        it('warns and returns first result', async () => {
+          const registries: RegistriesMock = {
+            'https://reg1.com': { releases: [{ version: '1.0.0' }] },
+            'https://reg2.com': { releases: [{ version: '2.0.0' }] },
+            'https://reg3.com': null,
+          };
+          const registryUrls = Object.keys(registries);
+          datasources.set(datasource, new FirstRegistryDatasource(registries));
+
+          const res = await getPkgReleases({
+            datasource,
+            depName,
+            registryUrls,
+          });
+
+          expect(res).toMatchObject({
+            releases: [{ version: '1.0.0' }],
+            registryUrl: 'https://reg1.com',
+          });
+          expect(logger.logger.warn).toHaveBeenCalledWith(
+            {
+              datasource: 'dummy',
+              depName: 'package',
+              registryUrls,
+            },
+            'Excess registryUrls found for datasource lookup - using first configured only'
+          );
+        });
+
+        it('warns and returns first null', async () => {
+          const registries: RegistriesMock = {
+            'https://reg1.com': null,
+            'https://reg2.com': { releases: [{ version: '1.2.3' }] },
+          };
+          const registryUrls = Object.keys(registries);
+          datasources.set(datasource, new FirstRegistryDatasource(registries));
+
+          const res = await getPkgReleases({
+            datasource,
+            depName,
+            registryUrls,
+          });
+
+          expect(res).toBeNull();
+          expect(logger.logger.warn).toHaveBeenCalledWith(
+            { datasource, depName, registryUrls },
+            'Excess registryUrls found for datasource lookup - using first configured only'
+          );
+        });
+      });
+
+      describe('merge', () => {
+        class MergeRegistriesDatasource extends DummyDatasource {
+          override readonly registryStrategy = 'merge';
+          override readonly defaultRegistryUrls = [
+            'https://reg1.com',
+            'https://reg2.com',
+          ];
+        }
+
+        const registries: RegistriesMock = {
+          'https://reg1.com': () => ({ releases: [{ version: '1.0.0' }] }),
+          'https://reg2.com': () => ({ releases: [{ version: '1.1.0' }] }),
+          'https://reg3.com': () => {
+            throw new ExternalHostError(new Error());
+          },
+          'https://reg4.com': () => {
+            throw new Error('a');
+          },
+          'https://reg5.com': () => {
+            throw new Error('b');
+          },
+        };
+
+        beforeEach(() => {
+          datasources.set(
+            datasource,
+            new MergeRegistriesDatasource(registries)
+          );
+        });
+
+        it('merges custom defaultRegistryUrls and returns success', async () => {
+          const res = await getPkgReleases({ datasource, depName });
+
+          expect(res).toMatchObject({
+            releases: [
+              { registryUrl: 'https://reg1.com', version: '1.0.0' },
+              { registryUrl: 'https://reg2.com', version: '1.1.0' },
+            ],
+          });
+        });
+
+        it('ignores custom defaultRegistryUrls if registrUrls are set', async () => {
+          const res = await getPkgReleases({
+            datasource,
+            depName,
+            defaultRegistryUrls: ['https://reg3.com'],
+            registryUrls: ['https://reg1.com', 'https://reg2.com'],
+          });
+
+          expect(res).toMatchObject({
+            releases: [
+              { registryUrl: 'https://reg1.com', version: '1.0.0' },
+              { registryUrl: 'https://reg2.com', version: '1.1.0' },
+            ],
+          });
+        });
+
+        it('merges registries and returns success', async () => {
+          const res = await getPkgReleases({
+            datasource,
+            depName,
+            registryUrls: ['https://reg1.com', 'https://reg2.com'],
+          });
+          expect(res).toMatchObject({
+            releases: [
+              { registryUrl: 'https://reg1.com', version: '1.0.0' },
+              { registryUrl: 'https://reg2.com', version: '1.1.0' },
+            ],
+          });
+        });
+
+        it('merges registries and aborts on ExternalHostError', async () => {
+          await expect(
+            getPkgReleases({
+              datasource,
+              depName,
+              registryUrls: [
+                'https://reg1.com',
+                'https://reg2.com',
+                'https://reg3.com',
+              ],
+            })
+          ).rejects.toThrow(EXTERNAL_HOST_ERROR);
+        });
+
+        it('merges registries and returns null for error', async () => {
+          expect(
+            await getPkgReleases({
+              datasource,
+              depName,
+              registryUrls: ['https://reg4.com', 'https://reg5.com'],
+            })
+          ).toBeNull();
+        });
+      });
+
+      describe('hunt', () => {
+        class HuntRegistriyDatasource extends DummyDatasource {
+          override readonly registryStrategy = 'hunt';
+        }
+
+        it('returns first successful result', async () => {
+          const registries: RegistriesMock = {
+            'https://reg1.com': null,
+            'https://reg2.com': () => {
+              throw new Error('unknown');
+            },
+            'https://reg3.com': { releases: [{ version: '1.0.0' }] },
+            'https://reg4.com': { releases: [{ version: '2.0.0' }] },
+            'https://reg5.com': { releases: [{ version: '3.0.0' }] },
+          };
+          const registryUrls = Object.keys(registries);
+          datasources.set(datasource, new HuntRegistriyDatasource(registries));
+
+          const res = await getPkgReleases({
+            datasource,
+            depName,
+            registryUrls,
+          });
+
+          expect(res).toMatchObject({
+            registryUrl: 'https://reg3.com',
+            releases: [{ version: '1.0.0' }],
+          });
+        });
+
+        it('returns null for HOST_DISABLED', async () => {
+          const registries: RegistriesMock = {
+            'https://reg1.com': () => {
+              throw new ExternalHostError(new Error(HOST_DISABLED));
+            },
+            'https://reg2.com': { releases: [{ version: '1.0.0' }] },
+          };
+          const registryUrls = Object.keys(registries);
+          datasources.set(datasource, new HuntRegistriyDatasource(registries));
+
+          const res = await getPkgReleases({
+            datasource,
+            depName,
+            registryUrls,
+          });
+
+          expect(res).toBeNull();
+        });
+
+        it('aborts on ExternalHostError', async () => {
+          const registries: RegistriesMock = {
+            'https://reg1.com': () => {
+              throw new ExternalHostError(new Error('something unknown'));
+            },
+            'https://reg2.com': { releases: [{ version: '1.0.0' }] },
+          };
+          const registryUrls = Object.keys(registries);
+          datasources.set(datasource, new HuntRegistriyDatasource(registries));
+
+          await expect(
+            getPkgReleases({ datasource, depName, registryUrls })
+          ).rejects.toThrow(EXTERNAL_HOST_ERROR);
+        });
+
+        it('returns null if no releases are found', async () => {
+          const registries: RegistriesMock = {
+            'https://reg1.com': () => {
+              throw new Error('a');
+            },
+            'https://reg2.com': () => {
+              throw new Error('b');
+            },
+          };
+          const registryUrls = Object.keys(registries);
+          datasources.set(datasource, new HuntRegistriyDatasource(registries));
+
+          const res = await getPkgReleases({
+            datasource,
+            depName,
+            registryUrls,
+          });
+
+          expect(res).toBeNull();
+        });
+      });
     });
-    expect(res.replacementName).toBe('def');
-    expect(res.replacementVersion).toBe('2.0.0');
   });
 });
diff --git a/lib/datasource/metadata-manual.ts b/lib/datasource/metadata-manual.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0144de98d5ea7cb6f56f0febfb15f1077ad3880d
--- /dev/null
+++ b/lib/datasource/metadata-manual.ts
@@ -0,0 +1,103 @@
+// Use this object to define changelog URLs for packages
+// Only necessary when the changelog data cannot be found in the package's source repository
+export const manualChangelogUrls: Record<string, Record<string, string>> = {
+  npm: {
+    'babel-preset-react-app':
+      'https://github.com/facebook/create-react-app/releases',
+    firebase: 'https://firebase.google.com/support/release-notes/js',
+    'flow-bin': 'https://github.com/facebook/flow/blob/master/Changelog.md',
+    gatsby:
+      'https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby/CHANGELOG.md',
+    'react-native':
+      'https://github.com/react-native-community/react-native-releases/blob/master/CHANGELOG.md',
+    sharp: 'https://github.com/lovell/sharp/blob/master/docs/changelog.md',
+    'tailwindcss-classnames':
+      'https://github.com/muhammadsammy/tailwindcss-classnames/blob/master/CHANGELOG.md',
+    'zone.js':
+      'https://github.com/angular/angular/blob/master/packages/zone.js/CHANGELOG.md',
+  },
+  pypi: {
+    alembic: 'https://alembic.sqlalchemy.org/en/latest/changelog.html',
+    beautifulsoup4:
+      'https://bazaar.launchpad.net/~leonardr/beautifulsoup/bs4/view/head:/CHANGELOG',
+    django: 'https://github.com/django/django/tree/master/docs/releases',
+    djangorestframework:
+      'https://www.django-rest-framework.org/community/release-notes/',
+    flake8: 'http://flake8.pycqa.org/en/latest/release-notes/index.html',
+    'django-storages':
+      'https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst',
+    hypothesis:
+      'https://github.com/HypothesisWorks/hypothesis/blob/master/hypothesis-python/docs/changes.rst',
+    lxml: 'https://git.launchpad.net/lxml/plain/CHANGES.txt',
+    mypy: 'https://mypy-lang.blogspot.com/',
+    phonenumbers:
+      'https://github.com/daviddrysdale/python-phonenumbers/blob/dev/python/HISTORY.md',
+    psycopg2: 'http://initd.org/psycopg/articles/tag/release/',
+    'psycopg2-binary': 'http://initd.org/psycopg/articles/tag/release/',
+    pycountry:
+      'https://github.com/flyingcircusio/pycountry/blob/master/HISTORY.txt',
+    'django-debug-toolbar':
+      'https://django-debug-toolbar.readthedocs.io/en/latest/changes.html',
+    'firebase-admin':
+      'https://firebase.google.com/support/release-notes/admin/python',
+    requests: 'https://github.com/psf/requests/blob/master/HISTORY.md',
+    sqlalchemy: 'https://docs.sqlalchemy.org/en/latest/changelog/',
+    uwsgi: 'https://uwsgi-docs.readthedocs.io/en/latest/#release-notes',
+    wagtail: 'https://github.com/wagtail/wagtail/tree/master/docs/releases',
+  },
+  docker: {
+    'gitlab/gitlab-ce':
+      'https://gitlab.com/gitlab-org/gitlab-foss/-/blob/master/CHANGELOG.md',
+    'gitlab/gitlab-runner':
+      'https://gitlab.com/gitlab-org/gitlab-runner/-/blob/master/CHANGELOG.md',
+    'google/cloud-sdk': 'https://cloud.google.com/sdk/docs/release-notes',
+    neo4j: 'https://neo4j.com/release-notes/',
+    'whitesource/renovate': 'https://github.com/whitesource/renovate-on-prem',
+  },
+};
+
+// Use this object to define manual source URLs for packages
+// Only necessary if the datasource is unable to locate the source URL itself
+export const manualSourceUrls: Record<string, Record<string, string>> = {
+  orb: {
+    'cypress-io/cypress': 'https://github.com/cypress-io/circleci-orb',
+    'hutson/library-release-workflows':
+      'https://github.com/hyper-expanse/library-release-workflows',
+  },
+  docker: {
+    'amd64/registry': 'https://github.com/distribution/distribution',
+    'amd64/traefik': 'https://github.com/containous/traefik',
+    'coredns/coredns': 'https://github.com/coredns/coredns',
+    'docker/compose': 'https://github.com/docker/compose',
+    'drone/drone': 'https://github.com/drone/drone',
+    'drone/drone-runner-docker':
+      'https://github.com/drone-runners/drone-runner-docker',
+    'drone/drone-runner-kube':
+      'https://github.com/drone-runners/drone-runner-kube',
+    'drone/drone-runner-ssh':
+      'https://github.com/drone-runners/drone-runner-ssh',
+    'gcr.io/kaniko-project/executor':
+      'https://github.com/GoogleContainerTools/kaniko',
+    'gitlab/gitlab-ce': 'https://gitlab.com/gitlab-org/gitlab-foss',
+    'gitlab/gitlab-runner': 'https://gitlab.com/gitlab-org/gitlab-runner',
+    'gitea/gitea': 'https://github.com/go-gitea/gitea',
+    'hashicorp/terraform': 'https://github.com/hashicorp/terraform',
+    node: 'https://github.com/nodejs/node',
+    registry: 'https://github.com/distribution/distribution',
+    traefik: 'https://github.com/containous/traefik',
+  },
+  kubernetes: {
+    node: 'https://github.com/nodejs/node',
+  },
+  npm: {
+    node: 'https://github.com/nodejs/node',
+  },
+  nvm: {
+    node: 'https://github.com/nodejs/node',
+  },
+  pypi: {
+    mkdocs: 'https://github.com/mkdocs/mkdocs',
+    'mkdocs-material': 'https://github.com/squidfunk/mkdocs-material',
+    mypy: 'https://github.com/python/mypy',
+  },
+};
diff --git a/lib/datasource/metadata.ts b/lib/datasource/metadata.ts
index 927dfe99e19c6fd6fe964b622793db9f130cec6e..e2e970cf7dd95faf8aa1edadd6f70153de0fa463 100644
--- a/lib/datasource/metadata.ts
+++ b/lib/datasource/metadata.ts
@@ -5,112 +5,9 @@ import { DateTime } from 'luxon';
 import * as hostRules from '../util/host-rules';
 import { regEx } from '../util/regex';
 import { validateUrl } from '../util/url';
+import { manualChangelogUrls, manualSourceUrls } from './metadata-manual';
 import type { ReleaseResult } from './types';
 
-// Use this object to define changelog URLs for packages
-// Only necessary when the changelog data cannot be found in the package's source repository
-const manualChangelogUrls: Record<string, Record<string, string>> = {
-  npm: {
-    'babel-preset-react-app':
-      'https://github.com/facebook/create-react-app/releases',
-    firebase: 'https://firebase.google.com/support/release-notes/js',
-    'flow-bin': 'https://github.com/facebook/flow/blob/master/Changelog.md',
-    gatsby:
-      'https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby/CHANGELOG.md',
-    'react-native':
-      'https://github.com/react-native-community/react-native-releases/blob/master/CHANGELOG.md',
-    sharp: 'https://github.com/lovell/sharp/blob/master/docs/changelog.md',
-    'tailwindcss-classnames':
-      'https://github.com/muhammadsammy/tailwindcss-classnames/blob/master/CHANGELOG.md',
-    'zone.js':
-      'https://github.com/angular/angular/blob/master/packages/zone.js/CHANGELOG.md',
-  },
-  pypi: {
-    alembic: 'https://alembic.sqlalchemy.org/en/latest/changelog.html',
-    beautifulsoup4:
-      'https://bazaar.launchpad.net/~leonardr/beautifulsoup/bs4/view/head:/CHANGELOG',
-    django: 'https://github.com/django/django/tree/master/docs/releases',
-    djangorestframework:
-      'https://www.django-rest-framework.org/community/release-notes/',
-    flake8: 'http://flake8.pycqa.org/en/latest/release-notes/index.html',
-    'django-storages':
-      'https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst',
-    hypothesis:
-      'https://github.com/HypothesisWorks/hypothesis/blob/master/hypothesis-python/docs/changes.rst',
-    lxml: 'https://git.launchpad.net/lxml/plain/CHANGES.txt',
-    mypy: 'https://mypy-lang.blogspot.com/',
-    phonenumbers:
-      'https://github.com/daviddrysdale/python-phonenumbers/blob/dev/python/HISTORY.md',
-    psycopg2: 'http://initd.org/psycopg/articles/tag/release/',
-    'psycopg2-binary': 'http://initd.org/psycopg/articles/tag/release/',
-    pycountry:
-      'https://github.com/flyingcircusio/pycountry/blob/master/HISTORY.txt',
-    'django-debug-toolbar':
-      'https://django-debug-toolbar.readthedocs.io/en/latest/changes.html',
-    'firebase-admin':
-      'https://firebase.google.com/support/release-notes/admin/python',
-    requests: 'https://github.com/psf/requests/blob/master/HISTORY.md',
-    sqlalchemy: 'https://docs.sqlalchemy.org/en/latest/changelog/',
-    uwsgi: 'https://uwsgi-docs.readthedocs.io/en/latest/#release-notes',
-    wagtail: 'https://github.com/wagtail/wagtail/tree/master/docs/releases',
-  },
-  docker: {
-    'gitlab/gitlab-ce':
-      'https://gitlab.com/gitlab-org/gitlab-foss/-/blob/master/CHANGELOG.md',
-    'gitlab/gitlab-runner':
-      'https://gitlab.com/gitlab-org/gitlab-runner/-/blob/master/CHANGELOG.md',
-    'google/cloud-sdk': 'https://cloud.google.com/sdk/docs/release-notes',
-    neo4j: 'https://neo4j.com/release-notes/',
-    'whitesource/renovate': 'https://github.com/whitesource/renovate-on-prem',
-  },
-};
-
-// Use this object to define manual source URLs for packages
-// Only necessary if the datasource is unable to locate the source URL itself
-const manualSourceUrls: Record<string, Record<string, string>> = {
-  orb: {
-    'cypress-io/cypress': 'https://github.com/cypress-io/circleci-orb',
-    'hutson/library-release-workflows':
-      'https://github.com/hyper-expanse/library-release-workflows',
-  },
-  docker: {
-    'amd64/registry': 'https://github.com/distribution/distribution',
-    'amd64/traefik': 'https://github.com/containous/traefik',
-    'coredns/coredns': 'https://github.com/coredns/coredns',
-    'docker/compose': 'https://github.com/docker/compose',
-    'drone/drone': 'https://github.com/drone/drone',
-    'drone/drone-runner-docker':
-      'https://github.com/drone-runners/drone-runner-docker',
-    'drone/drone-runner-kube':
-      'https://github.com/drone-runners/drone-runner-kube',
-    'drone/drone-runner-ssh':
-      'https://github.com/drone-runners/drone-runner-ssh',
-    'gcr.io/kaniko-project/executor':
-      'https://github.com/GoogleContainerTools/kaniko',
-    'gitlab/gitlab-ce': 'https://gitlab.com/gitlab-org/gitlab-foss',
-    'gitlab/gitlab-runner': 'https://gitlab.com/gitlab-org/gitlab-runner',
-    'gitea/gitea': 'https://github.com/go-gitea/gitea',
-    'hashicorp/terraform': 'https://github.com/hashicorp/terraform',
-    node: 'https://github.com/nodejs/node',
-    registry: 'https://github.com/distribution/distribution',
-    traefik: 'https://github.com/containous/traefik',
-  },
-  kubernetes: {
-    node: 'https://github.com/nodejs/node',
-  },
-  npm: {
-    node: 'https://github.com/nodejs/node',
-  },
-  nvm: {
-    node: 'https://github.com/nodejs/node',
-  },
-  pypi: {
-    mkdocs: 'https://github.com/mkdocs/mkdocs',
-    'mkdocs-material': 'https://github.com/squidfunk/mkdocs-material',
-    mypy: 'https://github.com/python/mypy',
-  },
-};
-
 const githubPages = regEx('^https://([^.]+).github.com/([^/]+)$');
 const gitPrefix = regEx('^git:/?/?');