Repository: ripple/explorer (ts)

Issue:
Use `<Helmet>` to set page title
Many places in the app set the page's title by directly changing `document.title`.  Instead `<Helmet>` should be used.

Pull request description:
feat: use `<Helmet>` to set page title
## High Level Overview of Change

Previous code was manually setting `document.title`.  This also defines `QuickHarness` which simplifies setting up `QueryClientProvider`, `I18nextProvider`, `HelmetProvider`, and `MemoryRouter`.

Closes #650

### Context of Change

The need to define `HelmetProvided` in tests pushed me over the edge needed to create something cleaner.

### Type of Change

<!--
Please check relevant options, delete irrelevant ones.
-->

- [x] New feature (non-breaking change which adds functionality)
- [x] Refactor (non-breaking change that only restructures code)
- [x] Tests (You added tests for code that already exists, or your new feature included in this PR)

### TypeScript/Hooks Update

- [x] Updated files to TypeScript

Patch:
diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json
index e7f12eea..15f0aad3 100644
--- a/public/locales/en-US/translations.json
+++ b/public/locales/en-US/translations.json
@@ -28,7 +28,7 @@
   "transactions": "Transactions",
   "transaction_short": "TX",
   "nodes": "Nodes",
-  "validator": "validator",
+  "validator": "Validator",
   "validators": "Validators",
   "upgrade_status": "Upgrade Status",
   "version": "v{{number}}",
diff --git a/src/containers/Accounts/AMM/AMMAccounts/index.tsx b/src/containers/Accounts/AMM/AMMAccounts/index.tsx
index 41bb3a75..8155000d 100644
--- a/src/containers/Accounts/AMM/AMMAccounts/index.tsx
+++ b/src/containers/Accounts/AMM/AMMAccounts/index.tsx
@@ -1,7 +1,7 @@
-import { useContext, useEffect } from 'react'
+import { FC, PropsWithChildren, useContext, useEffect } from 'react'
 import { useParams, useRouteMatch } from 'react-router'
-import { useTranslation } from 'react-i18next'
 import { useQuery } from 'react-query'
+import { Helmet } from 'react-helmet-async'
 import { useLanguage } from '../../../shared/hooks'
 import '../../styles.scss'
 import { formatAmount } from '../../../../rippled/lib/txSummary/formatAmount'
@@ -46,7 +46,6 @@ export const AMMAccounts = () => {
     tab: string
   }>()
   const { path = '/' } = useRouteMatch()
-  const { t } = useTranslation()
   const mainPath = `${path.split('/:')[0]}/${accountId}`
   const rippledSocket = useContext(SocketContext)
   const language = useLanguage()
@@ -116,13 +115,20 @@ export const AMMAccounts = () => {
     }
   })
 
-  document.title = `${t('xrpl_explorer')} | ${accountId.substr(0, 12)}...`
+  const Page: FC<PropsWithChildren<{}>> = ({ children }) => (
+    <div className="accounts-page">
+      <Helmet title={`AMM ${accountId.substr(0, 12)}...`} />
+      {children}
+    </div>
+  )
 
   const tabs = ['transactions']
 
-  return error ? (
-    renderError(error)
-  ) : (
+  if (error) {
+    return <Page>{renderError(error)}</Page>
+  }
+
+  return (
     <div className="accounts-page section">
       {data && (
         <>
diff --git a/src/containers/Accounts/index.jsx b/src/containers/Accounts/index.tsx
similarity index 84%
rename from src/containers/Accounts/index.jsx
rename to src/containers/Accounts/index.tsx
index 60763a44..a8f01caa 100644
--- a/src/containers/Accounts/index.jsx
+++ b/src/containers/Accounts/index.tsx
@@ -1,6 +1,6 @@
 import { useEffect, useState } from 'react'
+import { Helmet } from 'react-helmet-async'
 import { useParams, useRouteMatch } from 'react-router'
-import { useTranslation } from 'react-i18next'
 import AccountHeader from './AccountHeader'
 import { AccountTransactionTable } from './AccountTransactionTable'
 import './styles.scss'
@@ -9,9 +9,11 @@ import { Tabs } from '../shared/components/Tabs'
 import { AccountAssetTab } from './AccountAssetTab/AccountAssetTab'
 
 export const Accounts = () => {
-  const { id: accountId, tab = 'transactions' } = useParams()
+  const { id: accountId, tab = 'transactions' } = useParams<{
+    id: string
+    tab: 'assets' | 'transactions'
+  }>()
   const { path = '/' } = useRouteMatch()
-  const { t } = useTranslation()
   const [currencySelected, setCurrencySelected] = useState('XRP')
   const mainPath = `${path.split('/:')[0]}/${accountId}`
 
@@ -23,11 +25,11 @@ export const Accounts = () => {
     }
   })
 
-  document.title = `${t('xrpl_explorer')} | ${accountId.substr(0, 12)}...`
   const tabs = ['transactions', 'assets']
 
   return (
     <div className="accounts-page section">
+      <Helmet title={`${accountId.substr(0, 12)}...`} />
       {accountId && (
         <>
           <AccountHeader
@@ -40,6 +42,7 @@ export const Accounts = () => {
             <AccountTransactionTable
               accountId={accountId}
               currencySelected={currencySelected}
+              hasTokensColumn={false}
             />
           )}
           {tab === 'assets' && <AccountAssetTab />}
diff --git a/src/containers/App/index.tsx b/src/containers/App/index.tsx
index 15ceda44..50b020da 100644
--- a/src/containers/App/index.tsx
+++ b/src/containers/App/index.tsx
@@ -16,7 +16,7 @@ export const AppWrapper = () => {
     <HelmetProvider>
       <div className="app-wrapper">
         <AppErrorBoundary>
-          <Helmet>
+          <Helmet titleTemplate={`${t('xrpl_explorer')} | %s`} defer={false}>
             <meta name="description" content={t('app.meta.description')} />
             <meta name="author" content={t('app.meta.author')} />
           </Helmet>
diff --git a/src/containers/Ledger/index.tsx b/src/containers/Ledger/index.tsx
index cebab7af..31b12f31 100644
--- a/src/containers/Ledger/index.tsx
+++ b/src/containers/Ledger/index.tsx
@@ -1,4 +1,5 @@
 import { useContext, useEffect } from 'react'
+import { Helmet } from 'react-helmet-async'
 import { useTranslation } from 'react-i18next'
 import { useParams } from 'react-router'
 import { Link } from 'react-router-dom'
@@ -62,7 +63,6 @@ export const Ledger = () => {
   const language = useLanguage()
 
   useEffect(() => {
-    document.title = `${t('xrpl_explorer')} | ${t('ledger')} ${identifier}`
     analytics(ANALYTIC_TYPES.pageview, {
       title: 'Ledger',
       path: '/ledgers/:id',
@@ -166,6 +166,7 @@ export const Ledger = () => {
 
   return (
     <div className="ledger-page">
+      <Helmet title={`${t('ledger')} ${identifier}`} />
       {isLoading && <Loader />}
       {renderLedger()}
       {renderError()}
diff --git a/src/containers/Ledgers/index.tsx b/src/containers/Ledgers/index.tsx
index 0c451e4f..6aa8318a 100644
--- a/src/containers/Ledgers/index.tsx
+++ b/src/containers/Ledgers/index.tsx
@@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useQuery } from 'react-query'
 import axios from 'axios'
+import { Helmet } from 'react-helmet-async'
 import Log from '../shared/log'
 import {
   analytics,
@@ -32,8 +33,6 @@ const LedgersPage = () => {
   const network = useContext(NetworkContext)
   const language = useLanguage()
 
-  document.title = `${t('xrpl_explorer')} | ${t('ledgers')}`
-
   useEffect(() => {
     /* @ts-ignore */
     analytics(ANALYTIC_TYPES.pageview, { title: 'Ledgers', path: '/' })
@@ -83,7 +82,7 @@ const LedgersPage = () => {
 
   return (
     <div className="ledgers-page">
-      {/* @ts-ignore */}
+      <Helmet title={t('ledgers')} />
       {isOnline && (
         <Streams
           validators={validators}
diff --git a/src/containers/NFT/NFT.tsx b/src/containers/NFT/NFT.tsx
index 26b9d084..3951d65b 100644
--- a/src/containers/NFT/NFT.tsx
+++ b/src/containers/NFT/NFT.tsx
@@ -1,6 +1,6 @@
-import { useEffect, useState } from 'react'
+import { FC, PropsWithChildren, useEffect, useState } from 'react'
 import { useParams } from 'react-router'
-import { useTranslation } from 'react-i18next'
+import { Helmet } from 'react-helmet-async'
 import NoMatch from '../NoMatch'
 import { NFTHeader } from './NFTHeader/NFTHeader'
 import { NFTTabs } from './NFTTabs/NFTTabs'
@@ -33,13 +33,9 @@ const getErrorMessage = (error: any) => ERROR_MESSAGES[error] ?? DEFAULT_ERROR
 
 export const NFT = () => {
   const { id: tokenId } = useParams<{ id: string }>()
-  const { t } = useTranslation()
   const [error, setError] = useState<number | null>(null)
 
-  document.title = `${t('xrpl_explorer')} | ${tokenId.substr(0, 12)}...`
-
   useEffect(() => {
-    /* @ts-ignore */
     analytics(ANALYTIC_TYPES.pageview, { title: 'NFT', path: '/nft/:id' })
     return () => {
       window.scrollTo(0, 0)
@@ -55,10 +51,18 @@ export const NFT = () => {
     )
   }
 
-  return error ? (
-    renderError()
-  ) : (
+  const Page: FC<PropsWithChildren<{}>> = ({ children }) => (
     <div className="nft-page">
+      <Helmet title={`NFT ${tokenId.substr(0, 12)}...`} />
+      {children}
+    </div>
+  )
+
+  if (error) {
+    return <Page>{renderError()}</Page>
+  }
+  return (
+    <Page>
       {tokenId && <NFTHeader tokenId={tokenId} setError={setError} />}
       {tokenId && <NFTTabs tokenId={tokenId} />}
       {!tokenId && (
@@ -66,6 +70,6 @@ export const NFT = () => {
           <h2>Enter a NFT ID in the search box</h2>
         </div>
       )}
-    </div>
+    </Page>
   )
 }
diff --git a/src/containers/Network/index.tsx b/src/containers/Network/index.tsx
index 596eba47..84c95c65 100644
--- a/src/containers/Network/index.tsx
+++ b/src/containers/Network/index.tsx
@@ -1,6 +1,7 @@
 import { useContext, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useParams } from 'react-router'
+import { Helmet } from 'react-helmet-async'
 import NetworkContext from '../shared/NetworkContext'
 import { Validators } from './Validators'
 import { UpgradeStatus } from './UpgradeStatus'
@@ -11,10 +12,11 @@ import './css/style.scss'
 
 export const Network = () => {
   const { t } = useTranslation()
-  const { tab = 'nodes' } = useParams<{ tab: string }>()
+  const { tab = 'nodes' } = useParams<{
+    tab: 'upgrade-status' | 'validators' | 'nodes'
+  }>()
   const network = useContext(NetworkContext)
   useEffect(() => {
-    document.title = `${t('xrpl_explorer')} | ${t('network')}`
     analytics(ANALYTIC_TYPES.pageview, {
       title: 'network',
       path: `/network/${tab || 'nodes'}`,
@@ -31,11 +33,15 @@ export const Network = () => {
     )
   }
 
-  if (tab === 'upgrade-status') {
-    return <UpgradeStatus />
-  }
-  if (tab === 'validators') {
-    return <Validators />
-  }
-  return <Nodes />
+  const Body = {
+    'upgrade-status': UpgradeStatus,
+    validators: Validators,
+    nodes: Nodes,
+  }[tab]
+  return (
+    <>
+      <Helmet title={t('network')} />
+      <Body />
+    </>
+  )
 }
diff --git a/src/containers/NoMatch/index.tsx b/src/containers/NoMatch/index.tsx
index 8517c3d7..1740ad04 100644
--- a/src/containers/NoMatch/index.tsx
+++ b/src/containers/NoMatch/index.tsx
@@ -1,4 +1,5 @@
 import { useContext, useEffect } from 'react'
+import { Helmet } from 'react-helmet-async'
 import { useTranslation } from 'react-i18next'
 import SocketContext from '../shared/SocketContext'
 import { analytics, ANALYTIC_TYPES } from '../shared/utils'
@@ -31,7 +32,6 @@ const NoMatch = ({
   const values = { connection: socket?.getState() }
 
   useEffect(() => {
-    document.title = `${t('xrpl_explorer')} | ${t(title as any)}`
     analytics(ANALYTIC_TYPES.pageview, {
       title: `${title} -- ${hints.join(', ')}`,
       path: '/404',
@@ -48,6 +48,7 @@ const NoMatch = ({
 
   return (
     <div className="no-match">
+      <Helmet title={t(title as any)} />
       {isError && <div className="uh-oh">{t('uh_oh')}</div>}
       <div className="title">{t(title as any, values)}</div>
       {hintMsg}
diff --git a/src/containers/PayStrings/index.tsx b/src/containers/PayStrings/index.tsx
index 7189d280..10b3dc30 100644
--- a/src/containers/PayStrings/index.tsx
+++ b/src/containers/PayStrings/index.tsx
@@ -1,8 +1,8 @@
 import { useEffect } from 'react'
-import { useTranslation } from 'react-i18next'
 import { useParams } from 'react-router'
 
 import { useQuery } from 'react-query'
+import { Helmet } from 'react-helmet-async'
 import { PayStringHeader } from './PayStringHeader'
 import { PayStringMappingsTable } from './PayStringMappingsTable'
 import NoMatch from '../NoMatch'
@@ -13,7 +13,6 @@ import { getPayString } from '../../rippled'
 
 export const PayString = () => {
   const { id: accountId = '' } = useParams<{ id: string }>()
-  const { t } = useTranslation()
 
   const { data, isError, isLoading } = useQuery(['paystring', accountId], () =>
     getPayString(accountId).catch((transactionRequestError) => {
@@ -29,9 +28,6 @@ export const PayString = () => {
   )
 
   useEffect(() => {
-    document.title = `${t('xrpl_explorer')} | ${accountId.substr(0, 24)}${
-      accountId.length > 24 ? '...' : ''
-    }`
     analytics(ANALYTIC_TYPES.pageview, {
       title: 'PayStrings',
       path: '/paystrings',
@@ -52,6 +48,11 @@ export const PayString = () => {
     renderError()
   ) : (
     <div className="paystring-page">
+      <Helmet
+        title={`${accountId.substring(0, 24)} ${
+          accountId.length > 24 ? '...' : ''
+        }`}
+      />
       {accountId && <PayStringHeader accountId={accountId} />}
       {accountId && <PayStringMappingsTable data={data} loading={isLoading} />}
       {!accountId && (
diff --git a/src/containers/Token/index.tsx b/src/containers/Token/index.tsx
index a40bc6ac..8aff1626 100644
--- a/src/containers/Token/index.tsx
+++ b/src/containers/Token/index.tsx
@@ -1,8 +1,9 @@
-import { FC, useEffect } from 'react'
+import { FC, PropsWithChildren, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
 import { connect } from 'react-redux'
 
 import { useParams } from 'react-router'
+import { Helmet } from 'react-helmet-async'
 import TokenHeader from './TokenHeader'
 import { TokenTransactionTable } from './TokenTransactionTable'
 import { DEXPairs } from './DEXPairs'
@@ -45,7 +46,6 @@ const Token: FC<{ error: string }> = ({ error }) => {
   const { t } = useTranslation()
 
   useEffect(() => {
-    document.title = `${t('xrpl_explorer')} | ${accountId.substr(0, 12)}...`
     analytics(ANALYTIC_TYPES.pageview, { title: 'Accounts', path: '/accounts' })
 
     return () => {
@@ -55,19 +55,22 @@ const Token: FC<{ error: string }> = ({ error }) => {
 
   const renderError = () => {
     const message = getErrorMessage(error)
-    return (
-      <div className="token-page">
-        <NoMatch title={message.title} hints={message.hints} />
-      </div>
-    )
+    return <NoMatch title={message.title} hints={message.hints} />
   }
 
+  const Page: FC<PropsWithChildren<{}>> = ({ children }) => (
+    <div className="token-page">
+      <Helmet title={`${accountId.substring(0, 12)}...`} />
+      {children}
+    </div>
+  )
+
   if (error) {
-    return renderError()
+    return <Page>{renderError()}</Page>
   }
 
   return (
-    <div className="token-page">
+    <Page>
       {accountId && <TokenHeader accountId={accountId} currency={currency} />}
       {accountId && IS_MAINNET && (
         <DEXPairs accountId={accountId} currency={currency} />
@@ -83,7 +86,7 @@ const Token: FC<{ error: string }> = ({ error }) => {
           <h2>Enter an account ID in the search box</h2>
         </div>
       )}
-    </div>
+    </Page>
   )
 }
 
diff --git a/src/containers/Transactions/index.tsx b/src/containers/Transactions/index.tsx
index 00635785..19f6d800 100644
--- a/src/containers/Transactions/index.tsx
+++ b/src/containers/Transactions/index.tsx
@@ -1,5 +1,6 @@
 import { useContext, useEffect } from 'react'
 import { useParams, useRouteMatch } from 'react-router'
+import { Helmet } from 'react-helmet-async'
 import { useTranslation } from 'react-i18next'
 import ReactJson from 'react-json-view'
 import { useQuery } from 'react-query'
@@ -72,11 +73,6 @@ export const Transaction = () => {
   )
   const { width } = useWindowSize()
 
-  const short = identifier.substr(0, 8)
-  document.title = `${t('xrpl_explorer')} | ${t(
-    'transaction_short',
-  )} ${short}...`
-
   useEffect(() => {
     analytics(ANALYTIC_TYPES.pageview, {
       title: 'Transaction',
@@ -159,6 +155,9 @@ export const Transaction = () => {
 
   return (
     <div className="transaction">
+      <Helmet
+        title={`${t('transaction_short')} ${identifier.substring(0, 8)}...`}
+      />
       {isLoading && <Loader />}
       {body}
     </div>
diff --git a/src/containers/Validators/index.tsx b/src/containers/Validators/index.tsx
index 75005458..1947869c 100644
--- a/src/containers/Validators/index.tsx
+++ b/src/containers/Validators/index.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
 import { useRouteMatch, useParams } from 'react-router-dom'
 import { useQuery } from 'react-query'
 import { useWindowSize } from 'usehooks-ts'
+import { Helmet } from 'react-helmet-async'
 import NoMatch from '../NoMatch'
 import Loader from '../shared/components/Loader'
 import { Tabs } from '../shared/components/Tabs'
@@ -76,18 +77,6 @@ export const Validator = () => {
     },
   )
 
-  useEffect(() => {
-    let short = ''
-    if (data?.domain) {
-      short = data.domain
-    } else if (data?.master_key) {
-      short = `${data.master_key.substr(0, 8)}...`
-    } else if (data?.signing_key) {
-      short = `${data.signing_key.substr(0, 8)}...`
-    }
-    document.title = `Validator ${short} | ${t('xrpl_explorer')}`
-  }, [data, t])
-
   useEffect(() => {
     analytics(ANALYTIC_TYPES.pageview, {
       title: 'Validator',
@@ -136,6 +125,23 @@ export const Validator = () => {
       })
   }
 
+  function renderPageTitle() {
+    if (!data) {
+      return <></>
+    }
+
+    let short = ''
+    if (data.domain) {
+      short = data.domain
+    } else if (data.master_key) {
+      short = `${data.master_key.substr(0, 8)}...`
+    } else if (data.signing_key) {
+      short = `${data.signing_key.substr(0, 8)}...`
+    }
+
+    return <Helmet title={`${t('validator')} ${short}`} />
+  }
+
   function renderSummary() {
     let name = 'Unknown Validator'
     if (data?.domain) {
@@ -209,6 +215,7 @@ export const Validator = () => {
 
   return (
     <div className="validator">
+      {renderPageTitle()}
       {isLoading && <Loader />}
       {body}
     </div>


Test patch:
diff --git a/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx b/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx
index 18fb093c..cf00a844 100644
--- a/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx
+++ b/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx
@@ -1,15 +1,12 @@
 import { mount } from 'enzyme'
-import { I18nextProvider } from 'react-i18next'
-import { MemoryRouter, Route } from 'react-router'
-import { QueryClientProvider } from 'react-query'
+import { Route } from 'react-router'
 import i18n from '../../../../../i18n/testConfig'
 import * as rippled from '../../../../../rippled/lib/rippled'
 import NoMatch from '../../../../NoMatch'
 import { AMMAccountHeader } from '../AMMAccountHeader/AMMAccountHeader'
 import { AccountTransactionTable } from '../../../AccountTransactionTable'
 import { AMMAccounts } from '../index'
-import { testQueryClient } from '../../../../test/QueryClient'
-import { flushPromises } from '../../../../test/utils'
+import { flushPromises, QuickHarness } from '../../../../test/utils'
 
 function setSpy(accountTransactions: any, ammInfo: any) {
   const spyInfo = jest.spyOn(rippled, 'getAMMInfo')
@@ -53,13 +50,12 @@ describe('AMM Account Page', () => {
 
   const createWrapper = () =>
     mount(
-      <QueryClientProvider client={testQueryClient}>
-        <I18nextProvider i18n={i18n}>
-          <MemoryRouter initialEntries={[`accounts/${TEST_ACCOUNT_ID}`]}>
-            <Route path="accounts/:id" component={AMMAccounts} />
-          </MemoryRouter>
-        </I18nextProvider>
-      </QueryClientProvider>,
+      <QuickHarness
+        i18n={i18n}
+        initialEntries={[`accounts/${TEST_ACCOUNT_ID}`]}
+      >
+        <Route path="accounts/:id" component={AMMAccounts} />
+      </QuickHarness>,
     )
 
   it('renders AMM account page when TVL not present', async () => {
diff --git a/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.js b/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.js
index 9629d923..d4ca7568 100644
--- a/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.js
+++ b/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.js
@@ -1,13 +1,10 @@
 import { mount } from 'enzyme'
-import { I18nextProvider } from 'react-i18next'
-import { QueryClientProvider } from 'react-query'
-import { BrowserRouter as Router, Link } from 'react-router-dom'
+import { Link } from 'react-router-dom'
 import i18n from '../../../../i18n/testConfig'
 import { AccountTransactionTable } from '../index'
 import TEST_TRANSACTIONS_DATA from './mockTransactions.json'
 import { getAccountTransactions } from '../../../../rippled'
-import { testQueryClient } from '../../../test/QueryClient'
-import { flushPromises } from '../../../test/utils'
+import { flushPromises, QuickHarness } from '../../../test/utils'
 
 jest.mock('../../../../rippled', () => ({
   __esModule: true,
@@ -32,17 +29,13 @@ describe('AccountTransactionsTable container', () => {
   ) => {
     getAccountTransactions.mockImplementation(getAccountTransactionsImpl)
     return mount(
-      <QueryClientProvider client={testQueryClient}>
-        <I18nextProvider i18n={i18n}>
-          <Router>
-            <AccountTransactionTable
-              accountId={TEST_ACCOUNT_ID}
-              hasTokensColumn={state.hasToken}
-              currencySelected={currencySelected}
-            />
-          </Router>
-        </I18nextProvider>
-      </QueryClientProvider>,
+      <QuickHarness i18n={i18n}>
+        <AccountTransactionTable
+          accountId={TEST_ACCOUNT_ID}
+          hasTokensColumn={state.hasToken}
+          currencySelected={currencySelected}
+        />
+      </QuickHarness>,
     )
   }
 
diff --git a/src/containers/Accounts/test/index.test.js b/src/containers/Accounts/test/index.test.js
index ec6ff57f..723359b1 100644
--- a/src/containers/Accounts/test/index.test.js
+++ b/src/containers/Accounts/test/index.test.js
@@ -1,17 +1,15 @@
 import { mount } from 'enzyme'
-import { I18nextProvider } from 'react-i18next'
-import { QueryClientProvider } from 'react-query'
 import configureMockStore from 'redux-mock-store'
 import thunk from 'redux-thunk'
 import { Provider } from 'react-redux'
-import { MemoryRouter, Route } from 'react-router'
+import { Route } from 'react-router'
 import { initialState } from '../../../rootReducer'
 import i18n from '../../../i18n/testConfig'
 import { Accounts } from '../index'
 import AccountHeader from '../AccountHeader'
 import { AccountTransactionTable } from '../AccountTransactionTable'
 import mockAccountState from './mockAccountState.json'
-import { testQueryClient } from '../../test/QueryClient'
+import { QuickHarness } from '../../test/utils'
 
 describe('Account container', () => {
   const TEST_ACCOUNT_ID = 'rTEST_ACCOUNT'
@@ -21,15 +19,14 @@ describe('Account container', () => {
   const createWrapper = (state = {}) => {
     const store = mockStore({ ...initialState, ...state })
     return mount(
-      <QueryClientProvider client={testQueryClient}>
-        <I18nextProvider i18n={i18n}>
-          <Provider store={store}>
-            <MemoryRouter initialEntries={[`accounts/${TEST_ACCOUNT_ID}`]}>
-              <Route path="accounts/:id" component={Accounts} />
-            </MemoryRouter>
-          </Provider>
-        </I18nextProvider>
-      </QueryClientProvider>,
+      <QuickHarness
+        i18n={i18n}
+        initialEntries={[`accounts/${TEST_ACCOUNT_ID}`]}
+      >
+        <Provider store={store}>
+          <Route path="accounts/:id" component={Accounts} />
+        </Provider>
+      </QuickHarness>,
     )
   }
 
diff --git a/src/containers/App/test/App.test.js b/src/containers/App/test/App.test.js
index b27c7602..7d300f74 100644
--- a/src/containers/App/test/App.test.js
+++ b/src/containers/App/test/App.test.js
@@ -1,5 +1,6 @@
 import { mount } from 'enzyme'
 import moxios from 'moxios'
+import { Helmet } from 'react-helmet-async'
 import { MemoryRouter } from 'react-router'
 import { I18nextProvider } from 'react-i18next'
 import configureMockStore from 'redux-mock-store'
@@ -12,6 +13,8 @@ import MockWsClient from '../../test/mockWsClient'
 import { getAccountInfo } from '../../../rippled'
 import { flushPromises } from '../../test/utils'
 
+Helmet.defaultProps.defer = false
+
 // We need to mock `react-router-dom` because otherwise the BrowserRouter in `App` will
 // get confused about being inside another Router (the `MemoryRouter` in the `mount`),
 // and the routing won't actually happen in the test
@@ -161,7 +164,7 @@ describe('App container', () => {
     const id = 'rZaChweF5oXn'
     const wrapper = createWrapper(`/accounts/${id}#ssss`)
     return new Promise((r) => setTimeout(r, 200)).then(() => {
-      expect(document.title).toEqual(`xrpl_explorer | ${id}...`)
+      expect(document.title).toEqual(`${id}...`)
       wrapper.unmount()
     })
   })
@@ -170,7 +173,7 @@ describe('App container', () => {
     const id = 'XVVFXHFdehYhofb7XRWeJYV6kjTEwboaHpB9S1ruYMsuXcG'
     const wrapper = createWrapper(`/accounts/${id}#ssss`)
     return new Promise((r) => setTimeout(r, 200)).then(() => {
-      expect(document.title).toEqual(`xrpl_explorer | XVVFXHFdehYh...`)
+      expect(document.title).toEqual(`XVVFXHFdehYh...`)
       expect(mockGetAccountInfo).toBeCalledWith(
         expect.anything(),
         'rKV8HEL3vLc6q9waTiJcewdRdSFyx67QFb',
@@ -204,7 +207,7 @@ describe('App container', () => {
     const id = 'rZaChweF5oXn'
     const wrapper = createWrapper(`/#/graph/${id}#ssss`)
     return new Promise((r) => setTimeout(r, 200)).then(() => {
-      expect(document.title).toEqual(`xrpl_explorer | ${id}...`)
+      expect(document.title).toEqual(`${id}...`)
       wrapper.unmount()
     })
   })
diff --git a/src/containers/Ledger/test/Ledger.test.js b/src/containers/Ledger/test/Ledger.test.js
index 3289230b..c519ccc9 100644
--- a/src/containers/Ledger/test/Ledger.test.js
+++ b/src/containers/Ledger/test/Ledger.test.js
@@ -1,14 +1,11 @@
 import { mount } from 'enzyme'
-import { I18nextProvider } from 'react-i18next'
-import { MemoryRouter as Router, Route } from 'react-router-dom'
-import { QueryClientProvider } from 'react-query'
+import { Route } from 'react-router-dom'
 import mockLedger from './storedLedger.json'
 import i18n from '../../../i18n/testConfig'
 import { Ledger } from '../index'
 import { getLedger } from '../../../rippled'
-import { testQueryClient } from '../../test/QueryClient'
 import { Error as RippledError } from '../../../rippled/lib/utils'
-import { flushPromises } from '../../test/utils'
+import { QuickHarness, flushPromises } from '../../test/utils'
 
 jest.mock('../../../rippled', () => {
   const originalModule = jest.requireActual('../../../rippled')
@@ -25,13 +22,9 @@ const mockedGetLedger = getLedger
 describe('Ledger container', () => {
   const createWrapper = (identifier = 38079857) =>
     mount(
-      <I18nextProvider i18n={i18n}>
-        <QueryClientProvider client={testQueryClient}>
-          <Router initialEntries={[`/ledgers/${identifier}`]}>
-            <Route exact path="/ledgers/:identifier" component={Ledger} />
-          </Router>
-        </QueryClientProvider>
-      </I18nextProvider>,
+      <QuickHarness i18n={i18n} initialEntries={[`/ledgers/${identifier}`]}>
+        <Route exact path="/ledgers/:identifier" component={Ledger} />
+      </QuickHarness>,
     )
 
   afterEach(() => {
diff --git a/src/containers/Ledgers/test/LedgersPage.test.js b/src/containers/Ledgers/test/LedgersPage.test.js
index 1e6667b6..47a99989 100644
--- a/src/containers/Ledgers/test/LedgersPage.test.js
+++ b/src/containers/Ledgers/test/LedgersPage.test.js
@@ -1,12 +1,9 @@
 import { mount } from 'enzyme'
 import moxios from 'moxios'
 import WS from 'jest-websocket-mock'
-import { BrowserRouter as Router } from 'react-router-dom'
 import configureMockStore from 'redux-mock-store'
 import thunk from 'redux-thunk'
 import { Provider } from 'react-redux'
-import { I18nextProvider } from 'react-i18next'
-import { QueryClientProvider } from 'react-query'
 import i18n from '../../../i18n/testConfig'
 import Ledgers from '../index'
 import { initialState } from '../../../rootReducer'
@@ -17,7 +14,7 @@ import prevLedgerMessage from './mock/prevLedger.json'
 import ledgerMessage from './mock/ledger.json'
 import validationMessage from './mock/validation.json'
 import rippledResponses from './mock/rippled.json'
-import { testQueryClient } from '../../test/QueryClient'
+import { QuickHarness } from '../../test/utils'
 
 function sleep(ms) {
   return new Promise((resolve) => setTimeout(resolve, ms))
@@ -88,19 +85,15 @@ describe('Ledgers Page container', () => {
     const store = mockStore({ ...initialState })
 
     return mount(
-      <QueryClientProvider client={testQueryClient}>
-        <Router>
-          <I18nextProvider i18n={i18n}>
-            <Provider store={store}>
-              <SocketContext.Provider value={client}>
-                <NetworkContext.Provider value={props.network}>
-                  <Ledgers msg={props.msg} />
-                </NetworkContext.Provider>
-              </SocketContext.Provider>
-            </Provider>
-          </I18nextProvider>
-        </Router>
-      </QueryClientProvider>,
+      <QuickHarness i18n={i18n}>
+        <Provider store={store}>
+          <SocketContext.Provider value={client}>
+            <NetworkContext.Provider value={props.network}>
+              <Ledgers msg={props.msg} />
+            </NetworkContext.Provider>
+          </SocketContext.Provider>
+        </Provider>
+      </QuickHarness>,
     )
   }
 
diff --git a/src/containers/NFT/test/NFT.test.js b/src/containers/NFT/test/NFT.test.js
index 7df7037a..dc0722d2 100644
--- a/src/containers/NFT/test/NFT.test.js
+++ b/src/containers/NFT/test/NFT.test.js
@@ -1,11 +1,9 @@
 import * as React from 'react'
 import { mount } from 'enzyme'
-import { I18nextProvider } from 'react-i18next'
-import { MemoryRouter as Router, Route } from 'react-router-dom'
-import { QueryClientProvider } from 'react-query'
+import { Route } from 'react-router-dom'
 import { NFT } from '../NFT'
 import i18n from '../../../i18n/testConfig'
-import { queryClient } from '../../shared/QueryClient'
+import { QuickHarness } from '../../test/utils'
 
 describe('NFT container', () => {
   const nftId =
@@ -13,13 +11,9 @@ describe('NFT container', () => {
 
   const createWrapper = (nft = undefined) =>
     mount(
-      <QueryClientProvider client={queryClient}>
-        <I18nextProvider i18n={i18n}>
-          <Router initialEntries={[`/nft/${nft}`]}>
-            <Route path="/nft/:id" component={NFT} />
-          </Router>
-        </I18nextProvider>
-      </QueryClientProvider>,
+      <QuickHarness i18n={i18n} initialEntries={[`/nft/${nft}`]}>
+        <Route path="/nft/:id" component={NFT} />
+      </QuickHarness>,
     )
 
   it('renders without crashing', () => {
diff --git a/src/containers/Network/test/nodes.test.js b/src/containers/Network/test/nodes.test.js
index 58f02d61..44f11e2f 100644
--- a/src/containers/Network/test/nodes.test.js
+++ b/src/containers/Network/test/nodes.test.js
@@ -1,14 +1,12 @@
 import { mount } from 'enzyme'
 import moxios from 'moxios'
-import { MemoryRouter as Router, Route } from 'react-router-dom'
-import { QueryClientProvider } from 'react-query'
-import { I18nextProvider } from 'react-i18next'
+import { Route } from 'react-router-dom'
 import i18n from '../../../i18n/testConfig'
 import { Network } from '../index'
 import mockNodes from './mockNodes.json'
-import { testQueryClient } from '../../test/QueryClient'
 import NetworkContext from '../../shared/NetworkContext'
 import countries from '../../../../public/countries.json'
+import { QuickHarness } from '../../test/utils'
 
 jest.mock('usehooks-ts', () => ({
   useWindowSize: () => ({
@@ -20,15 +18,11 @@ jest.mock('usehooks-ts', () => ({
 describe('Nodes Page container', () => {
   const createWrapper = () =>
     mount(
-      <QueryClientProvider client={testQueryClient}>
-        <I18nextProvider i18n={i18n}>
-          <NetworkContext.Provider value="main">
-            <Router initialEntries={['/network/nodes']}>
-              <Route path="/network/:tab" component={Network} />
-            </Router>
-          </NetworkContext.Provider>
-        </I18nextProvider>
-      </QueryClientProvider>,
+      <QuickHarness i18n={i18n} initialEntries={['/network/nodes']}>
+        <NetworkContext.Provider value="main">
+          <Route path="/network/:tab" component={Network} />
+        </NetworkContext.Provider>
+      </QuickHarness>,
     )
 
   const oldEnvs = process.env
diff --git a/src/containers/Network/test/validators.test.js b/src/containers/Network/test/validators.test.js
index 743b856a..7da99e49 100644
--- a/src/containers/Network/test/validators.test.js
+++ b/src/containers/Network/test/validators.test.js
@@ -1,16 +1,14 @@
 import { mount } from 'enzyme'
 import moxios from 'moxios'
 import WS from 'jest-websocket-mock'
-import { MemoryRouter as Router, Route } from 'react-router-dom'
-import { I18nextProvider } from 'react-i18next'
-import { QueryClientProvider } from 'react-query'
+import { Route } from 'react-router-dom'
 import i18n from '../../../i18n/testConfig'
 import { Network } from '../index'
 import mockValidators from './mockValidators.json'
 import validationMessage from './mockValidation.json'
 import SocketContext from '../../shared/SocketContext'
 import MockWsClient from '../../test/mockWsClient'
-import { testQueryClient } from '../../test/QueryClient'
+import { QuickHarness } from '../../test/utils'
 
 const WS_URL = 'ws://localhost:1234'
 
@@ -19,15 +17,11 @@ describe('Validators Tab container', () => {
   let client
   const createWrapper = () =>
     mount(
-      <QueryClientProvider client={testQueryClient}>
-        <I18nextProvider i18n={i18n}>
-          <SocketContext.Provider value={client}>
-            <Router initialEntries={['/network/validators']}>
-              <Route path="/network/:tab" component={Network} />
-            </Router>
-          </SocketContext.Provider>
-        </I18nextProvider>
-      </QueryClientProvider>,
+      <QuickHarness i18n={i18n} initialEntries={['/network/validators']}>
+        <SocketContext.Provider value={client}>
+          <Route path="/network/:tab" component={Network} />
+        </SocketContext.Provider>
+      </QuickHarness>,
     )
 
   beforeEach(async () => {
diff --git a/src/containers/NoMatch/test/NoMatch.test.tsx b/src/containers/NoMatch/test/NoMatch.test.tsx
index 9eb8f9c9..c5aee762 100644
--- a/src/containers/NoMatch/test/NoMatch.test.tsx
+++ b/src/containers/NoMatch/test/NoMatch.test.tsx
@@ -1,5 +1,6 @@
 import { mount } from 'enzyme'
 import { I18nextProvider } from 'react-i18next'
+import { HelmetProvider } from 'react-helmet-async'
 import MockWsClient from '../../test/mockWsClient'
 import SocketContext, { ExplorerXrplClient } from '../../shared/SocketContext'
 import i18n from '../../../i18n/testConfigEnglish'
@@ -13,7 +14,9 @@ describe('NoMatch container', () => {
     return mount(
       <I18nextProvider i18n={i18n}>
         <SocketContext.Provider value={client}>
-          <NoMatch {...props} />
+          <HelmetProvider>
+            <NoMatch {...props} />
+          </HelmetProvider>
         </SocketContext.Provider>
       </I18nextProvider>,
     )
diff --git a/src/containers/PayStrings/test/index.test.js b/src/containers/PayStrings/test/index.test.js
index ce4d0dd9..1a8bf1d3 100644
--- a/src/containers/PayStrings/test/index.test.js
+++ b/src/containers/PayStrings/test/index.test.js
@@ -1,13 +1,10 @@
 import { mount } from 'enzyme'
-import { I18nextProvider } from 'react-i18next'
-import { MemoryRouter as Router, Route } from 'react-router'
-import { QueryClientProvider } from 'react-query'
+import { Route } from 'react-router'
 import i18n from '../../../i18n/testConfig'
 import { PayString } from '../index'
 import { getPayString } from '../../../rippled'
-import { testQueryClient } from '../../test/QueryClient'
 import mockPayStringData from './mockPayStringData.json'
-import { flushPromises } from '../../test/utils'
+import { QuickHarness, flushPromises } from '../../test/utils'
 
 jest.mock('../../../rippled', () => {
   const originalModule = jest.requireActual('../../../rippled')
@@ -26,13 +23,9 @@ describe('PayString container', () => {
 
   const createWrapper = () =>
     mount(
-      <I18nextProvider i18n={i18n}>
-        <QueryClientProvider client={testQueryClient}>
-          <Router initialEntries={[`/paystrings/${TEST_PAY_ID}`]}>
-            <Route path="/paystrings/:id?" component={PayString} />
-          </Router>
-        </QueryClientProvider>
-      </I18nextProvider>,
+      <QuickHarness i18n={i18n} initialEntries={[`/paystrings/${TEST_PAY_ID}`]}>
+        <Route path="/paystrings/:id?" component={PayString} />
+      </QuickHarness>,
     )
 
   afterEach(() => {
diff --git a/src/containers/Token/test/index.test.js b/src/containers/Token/test/index.test.js
index 84f70d3c..8390eb20 100644
--- a/src/containers/Token/test/index.test.js
+++ b/src/containers/Token/test/index.test.js
@@ -1,17 +1,15 @@
 import { mount } from 'enzyme'
-import { I18nextProvider } from 'react-i18next'
-import { QueryClientProvider } from 'react-query'
 import configureMockStore from 'redux-mock-store'
 import thunk from 'redux-thunk'
 import { Provider } from 'react-redux'
-import { MemoryRouter as Router, Route } from 'react-router-dom'
+import { Route } from 'react-router-dom'
 import { initialState } from '../../../rootReducer'
 import i18n from '../../../i18n/testConfig'
 import Token from '../index'
 import TokenHeader from '../TokenHeader'
 import { TokenTransactionTable } from '../TokenTransactionTable'
 import mockAccountState from '../../Accounts/test/mockAccountState.json'
-import { queryClient } from '../../shared/QueryClient'
+import { QuickHarness } from '../../test/utils'
 
 describe('Token container', () => {
   const TEST_ACCOUNT_ID = 'rTEST_ACCOUNT'
@@ -21,15 +19,14 @@ describe('Token container', () => {
   const createWrapper = (state = {}) => {
     const store = mockStore({ ...initialState, ...state })
     return mount(
-      <QueryClientProvider client={queryClient}>
-        <I18nextProvider i18n={i18n}>
-          <Provider store={store}>
-            <Router initialEntries={[`/token/USD.${TEST_ACCOUNT_ID}`]}>
-              <Route exact path="/token/:currency.:id" component={Token} />
-            </Router>
-          </Provider>
-        </I18nextProvider>
-      </QueryClientProvider>,
+      <QuickHarness
+        i18n={i18n}
+        initialEntries={[`/token/USD.${TEST_ACCOUNT_ID}`]}
+      >
+        <Provider store={store}>
+          <Route exact path="/token/:currency.:id" component={Token} />
+        </Provider>
+      </QuickHarness>,
     )
   }
 
diff --git a/src/containers/Transactions/test/Transaction.test.js b/src/containers/Transactions/test/Transaction.test.js
index 2c3fd3ed..5301d3c7 100644
--- a/src/containers/Transactions/test/Transaction.test.js
+++ b/src/containers/Transactions/test/Transaction.test.js
@@ -1,16 +1,13 @@
 import { mount } from 'enzyme'
-import { I18nextProvider } from 'react-i18next'
-import { MemoryRouter as Router, Route } from 'react-router-dom'
-import { QueryClientProvider } from 'react-query'
+import { Route } from 'react-router-dom'
 import mockTransaction from './mock_data/Transaction.json'
 import mockTransactionSummary from './mock_data/TransactionSummary.json'
 import i18n from '../../../i18n/testConfig'
 import { Transaction } from '../index'
 import { TxStatus } from '../../shared/components/TxStatus'
-import { testQueryClient } from '../../test/QueryClient'
 import { getTransaction } from '../../../rippled'
 import { Error as RippledError } from '../../../rippled/lib/utils'
-import { flushPromises } from '../../test/utils'
+import { flushPromises, QuickHarness } from '../../test/utils'
 
 jest.mock('../../../rippled', () => {
   const originalModule = jest.requireActual('../../../rippled')
@@ -33,16 +30,15 @@ describe('Transaction container', () => {
     tab = 'simple',
   ) =>
     mount(
-      <QueryClientProvider client={testQueryClient}>
-        <I18nextProvider i18n={i18n}>
-          <Router initialEntries={[`/transactions/${hash}/${tab}`]}>
-            <Route
-              path="/transactions/:identifier?/:tab?"
-              component={Transaction}
-            />
-          </Router>
-        </I18nextProvider>
-      </QueryClientProvider>,
+      <QuickHarness
+        i18n={i18n}
+        initialEntries={[`/transactions/${hash}/${tab}`]}
+      >
+        <Route
+          path="/transactions/:identifier?/:tab?"
+          component={Transaction}
+        />
+      </QuickHarness>,
     )
   afterEach(() => {
     mockedGetTransaction.mockReset()
diff --git a/src/containers/Validators/test/Validator.test.js b/src/containers/Validators/test/Validator.test.js
index cd9b306c..ccad0b89 100644
--- a/src/containers/Validators/test/Validator.test.js
+++ b/src/containers/Validators/test/Validator.test.js
@@ -1,15 +1,15 @@
 import { mount } from 'enzyme'
 import moxios from 'moxios'
-import { I18nextProvider } from 'react-i18next'
-import { QueryClientProvider } from 'react-query'
-import { BrowserRouter as Router, useParams } from 'react-router-dom'
+import { useParams } from 'react-router-dom'
+import { Helmet } from 'react-helmet-async'
 import { BAD_REQUEST } from '../../shared/utils'
-import i18n from '../../../i18n/testConfig'
 import { Validator } from '../index'
 import { getLedger } from '../../../rippled'
-import { testQueryClient } from '../../test/QueryClient'
 import NetworkContext from '../../shared/NetworkContext'
-import { flushPromises } from '../../test/utils'
+import testConfigEnglish from '../../../i18n/testConfigEnglish'
+import { QuickHarness, flushPromises } from '../../test/utils'
+
+Helmet.defaultProps.defer = false
 
 global.location = '/validators/aaaa'
 
@@ -36,15 +36,11 @@ describe('Validator container', () => {
     getLedger.mockImplementation(props.getLedgerImpl || defaultGetLedgerImpl)
 
     return mount(
-      <QueryClientProvider client={testQueryClient}>
-        <I18nextProvider i18n={i18n}>
-          <NetworkContext.Provider value={props.network || 'main'}>
-            <Router>
-              <Validator />
-            </Router>
-          </NetworkContext.Provider>
-        </I18nextProvider>
-      </QueryClientProvider>,
+      <QuickHarness i18n={testConfigEnglish}>
+        <NetworkContext.Provider value={props.network || 'main'}>
+          <Validator />
+        </NetworkContext.Provider>
+      </QuickHarness>,
     )
   }
 
@@ -82,7 +78,7 @@ describe('Validator container', () => {
     const wrapper = createWrapper()
     await flushPromises()
     await flushPromises()
-    expect(document.title).toBe('Validator example.com | xrpl_explorer')
+    expect(document.title).toBe('Validator example.com')
     wrapper.unmount()
   })
 
@@ -100,7 +96,7 @@ describe('Validator container', () => {
     const wrapper = createWrapper()
     await flushPromises()
     await flushPromises()
-    expect(document.title).toBe('Validator foo... | xrpl_explorer')
+    expect(document.title).toBe('Validator foo...')
     wrapper.unmount()
   })
 
@@ -118,7 +114,7 @@ describe('Validator container', () => {
     const wrapper = createWrapper()
     await flushPromises()
     await flushPromises()
-    expect(document.title).toBe('Validator bar... | xrpl_explorer')
+    expect(document.title).toBe('Validator bar...')
     wrapper.unmount()
   })
 
@@ -147,7 +143,7 @@ describe('Validator container', () => {
     await flushPromises()
     expect(getLedger).toBeCalledTimes(1)
     expect(getLedger).toHaveBeenCalledWith('12345', undefined)
-    expect(document.title).toBe('Validator test.example.com | xrpl_explorer')
+    expect(document.title).toBe('Validator test.example.com')
     wrapper.unmount()
   })
 
diff --git a/src/containers/shared/components/Transaction/UNLModify/test/UNLModifySimple.test.tsx b/src/containers/shared/components/Transaction/UNLModify/test/UNLModifySimple.test.tsx
index a8fd6146..ef440a9f 100644
--- a/src/containers/shared/components/Transaction/UNLModify/test/UNLModifySimple.test.tsx
+++ b/src/containers/shared/components/Transaction/UNLModify/test/UNLModifySimple.test.tsx
@@ -11,7 +11,7 @@ const createWrapper = createSimpleWrapperFactory(Simple, i18n)
 describe('UNLModify: Simple', () => {
   it('renders tx that enables a validator', () => {
     const wrapper = createWrapper(mockUNLModifyEnable)
-    expectSimpleRowLabel(wrapper, 'validator', 'validator')
+    expectSimpleRowLabel(wrapper, 'validator', 'Validator')
     expectSimpleRowText(
       wrapper,
       'validator',
@@ -24,7 +24,7 @@ describe('UNLModify: Simple', () => {
 
   it('renders tx that disables a validator', () => {
     const wrapper = createWrapper(mockUNLModifyDisable)
-    expectSimpleRowLabel(wrapper, 'validator', 'validator')
+    expectSimpleRowLabel(wrapper, 'validator', 'Validator')
     expectSimpleRowText(
       wrapper,
       'validator',
diff --git a/src/containers/test/utils.ts b/src/containers/test/utils.ts
deleted file mode 100644
index 5a224827..00000000
--- a/src/containers/test/utils.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function flushPromises() {
-  return new Promise((resolve) => setImmediate(resolve))
-}
diff --git a/src/containers/test/utils.tsx b/src/containers/test/utils.tsx
new file mode 100644
index 00000000..36d1c690
--- /dev/null
+++ b/src/containers/test/utils.tsx
@@ -0,0 +1,26 @@
+import { FC, PropsWithChildren } from 'react'
+import { HelmetProvider } from 'react-helmet-async'
+import { I18nextProvider } from 'react-i18next'
+import { QueryClientProvider } from 'react-query'
+import { MemoryRouter } from 'react-router'
+import type i18n from '../../i18n/testConfig'
+import { testQueryClient } from './QueryClient'
+
+export function flushPromises() {
+  return new Promise((resolve) => setImmediate(resolve))
+}
+
+export const QuickHarness: FC<
+  PropsWithChildren<{
+    i18n: typeof i18n
+    initialEntries?: string[] | undefined
+  }>
+> = ({ i18n: i18nConfig, children, initialEntries }) => (
+  <QueryClientProvider client={testQueryClient}>
+    <I18nextProvider i18n={i18nConfig}>
+      <HelmetProvider>
+        <MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
+      </HelmetProvider>
+    </I18nextProvider>
+  </QueryClientProvider>
+)