An agreement check runs every night, comparing each customer's phone number in Airtable with the number in Salesforce, and for a month it reports no mismatches. That could mean the two systems agree. It could also mean the check compares the wrong field, matches no records, or quietly skips every customer it can't find in Salesforce. All four look the same from the outside: an empty exceptions table. To tell them apart, give the check cases you planted yourself, a mismatch and a customer missing from Salesforce, and see whether it reports both.
The same is true of most checks in Where to Start When the Landscape Is Already Live, the six-step work order in InAir's guide to operating Airtable in a connected landscape. Each step ends with a "Done when" condition: a forced failure reaches a named person, a malformed write lands in the holding table, a run with no changes sends nothing. Each one describes what a working step should show. None of them proves anything until you've seen the step handle the case on purpose, and most of those cases are ones you can't safely create in production. This post covers how to set up a sandbox copy of the base that an integration can run against, and how to build the test behind each check.
InAir's guide to controlling enterprise Airtable sprawl already sends "All testing of schema structures, new automation scripts, and interface configurations" to a sandbox, "a copy of the production base containing obfuscated test data." The Illusion of Maintainability describes the two ways to get one: duplicating the production base into a staging base, or Airtable's App Sandbox. Both are about changes made inside the base. An integration test is different, because the thing being tested runs outside the base and reaches it by base ID through a token or an OAuth grant, or by posting to an automation's webhook URL. A copy of the base only helps if the integration is actually pointed at it.
That's why this post uses a duplicated base rather than App Sandbox. Airtable's App sandbox documentation describes it as "a snapshot of your base's structure at the time it was created," available on Business and Enterprise Scale, where structural changes can later be reviewed and applied to production.
Either kind of sandbox can run these tests. Airtable’s App sandbox documentation describes it as “a snapshot of your base’s structure at the time it was created,” available on Business and Enterprise Scale, and the API treats a sandbox like any other base: it has its own base ID, so each table has its own API URL, and a When webhook received trigger in the sandbox gets a newly generated webhook URL. One difference matters for testing: an automation in a sandbox that sends notifications or reaches an external system runs only its trigger; all of its actions are skipped, so the run shows up in the run history without anything being sent. To test an automation in full, turn off skipping for that automation.
App Sandbox allows one sandbox per base, though, so tests kept there share it with every schema change built in the meantime. Where that gets in the way, or where your plan doesn’t include App Sandbox, duplicate the base instead. The setup below is written for a duplicate, and most of it applies to an App Sandbox too.
Duplicate the production base. Airtable lets you choose whether records and comments come with it, and the choice decides what you'll need to do next.
A copy with records is realistic, but it carries two risks. The bigger one is that every automation in the copy can act on real customers: if the next step leaves one outgoing action switched on, a test can send a confirmation email to a real customer or update a real client’s record in Salesforce.
A copy without records avoids that, because an action that reaches a fictitious email address, such as one at example.com, or a Salesforce ID that doesn’t exist, fails or reaches no one. A copy with records also puts real customer data in a second base, visible to everyone with access to it, which is exactly what the governance guide’s “obfuscated test data” is meant to avoid. The cost of a copy without records is that every test then needs data built for it.
For integration tests, start without records and load the data each test needs, described below. If a test depends on realistic volume, such as checking that a scoped sync stays quiet across thousands of unchanged records, still copy without records and generate that volume with a script or a CSV import. Overwriting personal fields in a copy made with records can leave the real values visible in each record's revision history.
Give the copy a name nobody will confuse with production, and keep it in a workspace whose collaborators are the people who run the tests.
A duplicated base can still reach the outside world. Before anything runs in it, open every automation in the copy and check each action that sends something out: Slack messages, emails, Run a script actions that call fetch, and actions that write to other systems, such as Jira or Google Sheets. Each one still points at whatever it pointed at in production. Point it at a test destination, such as a test Slack channel or the other system's sandbox, or turn the automation off until it is. Check the automation's subscriber list too.
Then look at every synced table in the copy. Airtable's article on creating and managing bases says a duplicated base "keeps the synced table and the records it held at the time of duplication," but "the copy is not connected to the original sync source," so replace any real data those tables hold with test data, and reconnect a sync only if a test needs it. Then turn to the list from step 1 of the pillar's work order, which names every integration that reads or writes the production base, and so every one that needs a sandbox version.
Create a personal access token for the account the integration writes as, with access to the sandbox base only. That account, whether a dedicated integration user or a service account on Enterprise Scale, needs to be a collaborator on the copy first. If the integration is ever run against the production base ID by mistake, a token with no access to that base gets a 403 instead of writing production data. Adding the sandbox to the production token's list of bases would also work, but it throws away the one safeguard that doesn't depend on someone entering the right ID.
Then change the integration's configuration: the base ID, and every table and field ID it uses. Don't assume the IDs in the copy match production. Read them from the copy: the base and table IDs appear in its URL, and the field IDs on its API documentation page. For a third party that posts to an automation's When webhook received trigger, use the URL shown in that automation in the copy.
When an integration writes to the other system as well as to Airtable, running the Airtable side against a copy while the other side still writes to the production CRM tests half the integration and changes real data in the other half.
Running the Airtable end against a copy while the other end still writes to the production CRM tests half the integration and changes real data in the other half. Connect the sandbox base to the other system's own test environment: a Salesforce sandbox, a test account in the support desk, a duplicate of the Zap that reads the sandbox base and posts to the test channel. Where a system has no test environment, point the integration at an endpoint that records what it receives and returns success, so you can see what would have been sent.
Every test needs records in a known state before it starts, and some need matching records in the other system. Keep these in a Test Fixtures table in the sandbox, one row per record: Target Table, Key Field, Key, and Values, holding the record's field values as JSON in the format the scripting API writes. Give every fixture a key that starts with TEST- in a Test Key text field, so test records are easy to find and impossible to confuse with real ones. Don't use the field the integration matches on when the other system assigns that value, as Salesforce does with record IDs: that field has to hold the real ID of the matching record in the other system's sandbox. A link field in Values holds record IDs from the copy, and the reset keeps each fixture's record ID by updating it in place, so link only to fixtures that no test deletes.
|
Check |
Fixtures it needs |
What the test does |
|---|---|---|
|
Failures reach a named person |
Five valid payloads for the integration to send, and an empty dead letter queue |
Forces one failure of each kind and checks who heard about it |
|
The agreement check finds mismatches |
Matching pairs in both sandboxes: one identical, one different, one differing only in formatting, plus one customer that exists only in Airtable |
Runs the check and compares its exceptions with the planted ones |
|
One edit to the settings record changes every tool |
The Settings record at 15%, and deals at 12%, 18% and 22% |
Changes the threshold to 20% and checks each tool's outcome |
|
Malformed writes stop at the gate |
Active and inactive people for the owner checks, and an account with a known ARR for the range check |
Sends malformed payloads and counts records on both sides of the gate |
|
A run with no changes sends nothing |
A set of records already in both systems, identical |
Runs the integration twice without changes, then once after one change |
Tests change data. A gate test leaves intake records and escalations behind, and rerunning the valid escalation with the same Event ID would then read Duplicate, because its first run already reached production. A reset script, run from the Scripting extension before each round of tests, puts every fixture back to its starting values, removes every TEST- record the fixtures don't list, and empties the tables that only ever hold what tests create, such as the dead letter queue, Escalation Intake and, in the sandbox, Escalations:
// Reset test fixtures. Run from the Scripting extension in the sandbox only.
// Test Fixtures fields: Target Table, Key Field, Key, Values (JSON).
// A row with Target Table and Key Field (any field in that table) but no Key
// deletes every record in the table: use it only for tables that hold nothing
// but what tests create.
const SANDBOX_BASE_ID = 'appXXXXXXXXXXXXXX'; // the sandbox copy's base ID
const PREFIX = 'TEST-';
if (base.id !== SANDBOX_BASE_ID) {
throw new Error(`Refusing to run: this is ${base.name}, not the sandbox`);
}
const fixtureQuery = await base.getTable('Test Fixtures').selectRecordsAsync({
fields: ['Target Table', 'Key Field', 'Key', 'Values'],
});
// Group the fixtures by table and key field.
const groups = new Map();
for (const f of fixtureQuery.records) {
const table = f.getCellValueAsString('Target Table');
if (!table) continue; // skip blank rows
const keyField = f.getCellValueAsString('Key Field');
const key = f.getCellValueAsString('Key');
const groupId = `${table}::${keyField}`;
if (!groups.has(groupId)) {
for (const g of groups.values()) {
if (g.table === table) throw new Error(`${table} has fixtures with two key fields`);
}
groups.set(groupId, { table, keyField, fixtures: new Map() });
}
if (!key) continue;
if (!key.startsWith(PREFIX)) throw new Error(`Fixture key ${key} must start with ${PREFIX}`);
let values;
try {
values = JSON.parse(f.getCellValueAsString('Values') || '{}');
} catch (e) {
throw new Error(`Fixture ${key} has invalid JSON in Values`);
}
groups.get(groupId).fixtures.set(key, values);
}
for (const { table, keyField, fixtures } of groups.values()) {
const target = base.getTable(table);
const query = await target.selectRecordsAsync({ fields: [keyField] });
const kept = new Map();
const toDelete = [];
for (const r of query.records) {
const key = r.getCellValueAsString(keyField);
// In a table with fixtures, never touch a record without a test key.
if (fixtures.size > 0 && !key.startsWith(PREFIX)) continue;
if (fixtures.has(key) && !kept.has(key)) kept.set(key, r.id);
else toDelete.push(r.id); // left behind by a test, or a second copy of a fixture
}
const toUpdate = [];
const toCreate = [];
for (const [key, values] of fixtures) {
const fields = { ...values, [keyField]: key };
if (kept.has(key)) toUpdate.push({ id: kept.get(key), fields });
else toCreate.push({ fields });
}
// The scripting API takes at most 50 records per call.
for (let i = 0; i < toDelete.length; i += 50) {
await target.deleteRecordsAsync(toDelete.slice(i, i + 50));
}
for (let i = 0; i < toUpdate.length; i += 50) {
await target.updateRecordsAsync(toUpdate.slice(i, i + 50));
}
for (let i = 0; i < toCreate.length; i += 50) {
await target.createRecordsAsync(toCreate.slice(i, i + 50));
}
output.text(`${table}: ${toUpdate.length} reset, ${toCreate.length} created, ${toDelete.length} removed`);
}
Because the script deletes records, the base ID check at the top refuses to run it anywhere but the copy, even if someone pastes it into production's Scripting extension by accident. Two details decide whether a reset is complete. An update only touches the fields in Values, so list every field a test can change, with null for fields that should start empty, such as a field an automation fills in on a deal. And the reset writes records like anything else, so automations in the sandbox run on them: changing the Settings record back to 15% recalculates every deal's flag, so reset before you start watching for results, not during a test.
The other system's fixtures need the same treatment in its own sandbox, by whatever means it offers, so that both sides start every round from the same known pairs.
Step 2's check is "a failure forced in a sandbox reaches a named person and leaves a record in the queue table." There are two kinds of failure to force, because they're reported in different ways.
The first is a write the integration can't complete. Rename a field the integration writes by name, then let it send at least five valid payloads. Each request fails with a 422, which is the systematic case in Retry Logic for Airtable Integrations, and its test list covers the other response codes the same way. Rename the field back afterwards. The check passes only if each failed payload appears in the dead letter queue with its error and payload, and the integration stops after the fifth and alerts the person named as its owner. If the integration writes by field ID, a rename won't break it, so send five payloads with a value the single select doesn't have instead. That fails with a 422 only if the integration sends without typecast. With typecast on, Airtable adds the option and the write succeeds, so check which one the integration does first. A message to the test channel that nobody is assigned to read doesn't count.
The second is an automation in the base failing. Give a test record a value that makes the automation's script action throw, such as leaving empty a link field the script requires. Airtable emails the automation's subscribers only while the automation is on, so turn it on for this test and check who is subscribed in the copy: whoever turns an automation on after creating it, or clicks Update after editing it, is added automatically, which usually means the tester. If the named owner isn't a workspace owner or base creator, they can't be added at all, so the script has to catch its own error, write the dead letter queue record and notify the owner itself. That's the version worth testing, because Airtable's own failure email can't reach an owner without that access.
Load the three phone number pairs into both sandboxes, and the unmatched customer into Airtable only, then run the check. A working check reports the pair with different numbers and the customer missing from Salesforce, reports the formatting pair only if it doesn't normalize values, and reports nothing else. Each fixture tests something different:
Match records on the ID one system stamps into the other, such as the Salesforce ID stored on each Airtable customer, never on a name or an email, and give each Airtable fixture the real ID of its record in the Salesforce sandbox. Finally, confirm the check changed nothing: the Phone Modified At field from Testing a New Airtable Rule in Log-Only Mode, or any Last modified time field set to watch Phone, should be unchanged on every fixture, and Phone should be unchanged on each fixture in the Salesforce sandbox. An agreement check that "fixes" what it finds has stopped reporting mismatches and started making a decision that belongs to the field's owner.
Only after this does the check go near production data. The first live run of a new agreement check can find a long list of existing mismatches, and step 3 of the work order is where they get settled. The sandbox run is what lets you trust that list, and later trust an empty one.
The pillar's example for step 4 is a discount threshold that lives in one Settings record and reaches each deal through a link and a rollup, with a Needs Review formula on each deal comparing the two. Its check names three tools: "the Airtable automation, the Zapier check and the CRM sync all switch to 20% from one edit." Each one needs a sandbox version: the automation in the copy, a duplicate of the Zap that reads the sandbox base and posts to the test channel, and the CRM integration pointed at the CRM's sandbox.
With the threshold at 15%, the deal at 18% should read Needs Review in all three tools: a new deal at 18%, with a TEST- key, starts the automation, the Zap posts its alert, and the CRM's stage-locking rule holds it. Change the Settings record to 20%. In the Zap and the CRM, the 18% deal should now pass, the 12% deal should still pass, and the 22% deal should still be flagged. The automation needs new deals to show its outcome, since an existing deal doesn't start a new run by leaving the flagged state or staying in it, so after the change, create a deal at 18% and one at 22%, each with a TEST- key so the next reset removes them. Only the 22% deal should start a run. A tool that keeps its own copy of the threshold shows up here as the one that still treats 18% as too high.
Two details make the test honest. Each tool acts on a deal only when it next reads it, so trigger that read: run the CRM integration, or make an edit the Zap picks up, rather than waiting. And the pillar asks you to test whether a recalculated flag fires an automation that watches it, so change the threshold back from 20% to 15% and check what happens to the 18% deal, whose flag turns on without anyone editing the deal. Whatever happens, decide whether it should, and record the answer with the test.
Step 5's check is "a malformed test write lands in the holding table with its reason attached, and production never sees it." For a staging layer like the one in Building a Staging Layer for Third-Party Webhooks, that post's test list gives the payloads to send, from a valid escalation to a write sent while a required field is renamed.
Send them from the real sender's sandbox if you can, rather than with a hand-built request, because the sender's own formatting can be what's malformed. For each one, check both sides of the gate. In the intake table, the record should have the expected status and a reason a person could act on. In the production table, count the records before and after the round, which should rise only by the requests meant to pass, and check that no escalation links to the intake record of any request meant to be held, since "production never sees it" means no record at all, not a record that was written and later corrected.
Step 6's check is "a sync run with no changes sends no writes," and record history can't show you the result. It only logs changes to a value, so a write that sets a field to the value it already holds doesn't appear there. The integration has to count what it sends.
Have the integration log, for every run, how many records it sent and how many requests it made, or route the sandbox run through an endpoint that records requests. Then run it twice with nothing changed in between. The second run should send nothing. That result alone doesn't prove much, since an integration that's broken also sends nothing, so change one record in the source system and run it again. Exactly one record should go out. How the integration decides what changed, and when it records the time of its last successful run, is covered in Change Detection for Airtable Integrations.
For a payload that an automation in Airtable sends to a third party, the same test works from the other side. If the automation's trigger watches only the fields in the agreed payload, editing a field outside it should leave no new run in the automation's run history, and editing one inside it should leave exactly one.
The step's other condition, that every third party still reading the base directly has a recorded reason, is a review of the step 1 list rather than something a sandbox can show.
A test run once proves the step worked on the day it was run. The pillar's advice on failure visibility, in its section on assessing Integration Load, applies to every one of these: "Worth re-testing after any change to the systems involved — a detection mechanism that worked before an integration was modified isn't guaranteed to still work after." That's only practical if the tests can be run again without rebuilding them.
Keep them in the sandbox, in an Integration Tests table with one record per test: the pillar step it proves, a link to its fixtures, what to do, what should happen, and Last Run, Result, and Run By. A round of testing is then a reset, a pass through the table, and a result on each row. A failed result records what happened instead of what was expected, and it stays failed until a later run passes.
Run the round after any change to an integration's code or configuration, to the schema of a table an integration touches, or to the other system, and also on a schedule, since the other system's changes aren't always announced.
The sandbox also has to keep up with production. When a field is added or a select option renamed in production, make the same change in the copy, or the tests keep passing against a schema production no longer has. A fresh duplicate solves the drift but has to be set up from the start: its automations point at production's destinations again, and it has a new base ID and possibly new table and field IDs, so every step in Setting Up the Sandbox has to be repeated, the Test Fixtures and Integration Tests tables have to be copied over from the old sandbox with any record IDs in Values updated, and the reset script needs the new ID before it will run.
These tests are also the evidence the Integration Load framework's failure visibility factor asks for, and each one records when detection was last shown to work.
If your integrations have never been run against anything but production and you'd like each of these checks proven before you rely on it, Schedule a Discovery Call with InAir and we'll build the sandbox and the tests with you.