Last Reviewed: Feb 2023
The problem:
You've just released a new Firebase/Javascript/React webapp for operational use. How can you ensure that you are able to continue system development work without risking the integrity of your live system?
In principle, Google's Firebase Emulator provides a way of running webapp code in a "sandbox" environment. In practice you may find this a frustrating tool:
- The Emulator only "watches" changes in your
functions
folder- it doesn't monitorsrc
folder changes. You'll miss this badly after you've been enjoying the seamless way that the React server responds to these changes. You have to rebuild your project whenever you want the emulator'slocalhost
page to show you the result of asrc
folder change (which, after all, will probably be the focus of most of your ongoing development activity). - Application code in the emulator needs to run
connectFunctionsEmulator
statements in order to connect to the Emulator's local collections, storage, and functions facilities. You may not be wildly happy about having to switch these statements in and out of test and live versions of your system. - Every emulator run has to re-initialise the webapp's local database at startup. While the Emulator enables you to export and import data sets from local storage, you may find it more natural to load your data within the application itself rather than in the terminal session that launches it.
- The files created by the emulator export option are complex "bundles" wrapping "snapshots" of all the collections in the webapp's database. you may find it more convenient to use editable jsons for individual collections.
All this said, use of the emulator will be essential at times. Is there a way of organising development practice to make life easier here (and ensure that you're not tempted to "cut corners")?
A solution :
- Use the Emulator only when this is absolutely essential which, if you're careful, will likely only be when you're working on a Cloud function. Plan to continue your development, in the main, using the React server's
localhost
page.localhost
) and ensure that it targets collections etc with names that reflect the run status. So, a collection that might have been initially conceived as "orders" will exist in Firestore under this proposal as both "live_orders" (say) and ""test_orders". In order to apply this arrangement universally, your code will also need to pass its run status to live functions. - Plan ahead for the inevitable moment when you will need to use the Emulator and invest in data setup tools to make this easier to use. I hope that Google won't feel I'm too harsh in my judgment of the Emulator- it's really a fine piece of work and the function logging that it supports has been invaluable to me. Life would have been very difficult indeed if I'd had to
deploy
functions every time I wanted to run a test.
TLDR :
How might this be achieved? Various solutions are available, but here's one that I've been using and which so far has proved highly effective.
In my system, all Firestore collections and Storage folders are addressed by instructions of the form:
const myCollRef = collection(db, runMode+"myCollectionName");
const myBucketTarget = runMode+"myBucketName";
Here, runMode
is a variable exported from a runMode
module that calculates its value by reference to window.location
:
// Returns "test_" if running in localhost, "emul_" if running in local host on the Emulator port and "live" otherwise
let constructRunMode = (x) => {
let result = "live_";
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") result = "test_";
if (result === "test_" && x.port === '5000') result = "emul_";
return result;
};
const runMode = constructRunMode(window.location)
export {runMode}
Obviously, the choice of test_
and live_
etc here is arbitrary, but I'm sure you'll get the idea. Firestore CRUD modules just need to import { runMode }
to obtain the necessary value.
You now need to consider how you communicate the runMode
setting to Cloud functions (which are of course oblivious to the status of the platform from which they are being called). This is quite a lot harder.
For callable functions, you can just include the runMode
parameter in the json that provides their calling parameter.
For onChange functions you have a fundamental difficulty because each onChange function is explicitly tied to the name of the collection on which it is called. Unavoidable, therefore, these functions need to be duplicated with names referencing the live_
, test_
etc variants of the collections on which they're called. In my own case, since I don't have too many onChange functions, I've not found this a problem - in fact, for my onChange transactional mail function, it gave me the opportunity to direct all email coming from system tests to safe destinations. I can only hope that you might be similarly lucky.
Another obvious objection to the use of the runMode
parameter in collection names etc is that it will only work if it is applied consistently. But you'll find that if you fail to do this, your webapp "fails soft" because Firestore will bounce a collection reference for which it has no rule. Your sacred live collections will survive unscathed.
To encourage discipline, I also prefix sub-collection names with runMode (though there's no technical requirement for this).
When, inevitably, the moment comes when you have to fall back on the Emulator, the runMode
system provides a simple mechanism for dealing with the connectFirestoreEmulator
problem described earlier. Just add the following lines to the central firebaseConfig.js file you're surely using to export db
, functions
and storage
constants around your webapp:
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
if (runMode === "emul_") connectFirestoreEmulator(db, 'localhost', 8080);
const functions = getFunctions(app, 'europe-west2');
if (runMode === "emul_") connectFunctionsEmulator(functions, "localhost", 5001);
const storage = getStorage();
if (runMode === "emul_") connectStorageEmulator(storage, "localhost", 9199);
Job done:
As regards my issues with the Emulator's approach to getting test data into an emulator run, my feeling is that these are actually concerns that affect all aspects of testing - you need good ways of getting data into your local work in the React server too.
My starting point for this was the realisation that I needed some way of creating a backup for my live Firestore database. This led me to develop a callable cloud "databaseDump" function that I could run periodically via Google's Cloud excellent, easy-to-use scheduling service. This unloads collections (and associated sub-collections) as JSONs into Cloud Storage files. It requires, naturally, a companion "databaseRestore" facility. Once I'd done this and had also created some generalised routines to create these JSONS on a collection-by-collection basis I realised that I'd got a way of seeding a test run with appropriate data on demand.
I'm not going to go into this into too much detail here but, in outline, my webapp now includes a "load/unload control" page which I can visit at any point to refresh selected collections for the current runMode
. This is available to webapps running in both the Firebase Emulator and the React server. It didn't take too long to set up, is easy and intuitive to use, and saves me a huge amount of time.
The next large React project I have to write will have all this built-in from the outset - I swear it!
I hope that someone finds this useful! Please feel free to correct me if you think I've misunderstood or mis-represented anything here.
Top comments (0)