Score 100% on the quiz to continue
Variables and declarations
var, let, and const, scope and hoisting, and why const does not mean immutable in a shared fixture.
Three keywords declare a variable in JavaScript, and one of them should never appear in code you write today. This lesson is about why, and about the far more interesting question hiding behind it: const does not mean what most people think it means, and the gap between what it means and what people think it means is where shared test fixtures go bad.
The three keywords
var industry = 'Retail'
let page = 1
const baseUrl = 'http://localhost:3000'All three create a variable. What separates them is scope, reassignment, and what happens before the line runs.
| Scope | Can be reassigned | Before the declaration line | |
|---|---|---|---|
var | The whole function | Yes | undefined |
let | The enclosing block { } | Yes | Throws a ReferenceError |
const | The enclosing block { } | No | Throws a ReferenceError |
Function scope versus block scope
A block is anything between curly braces: the body of an if, of a for, of a function, or a bare { } on its own.
let and const live inside the block they were declared in and vanish at its closing brace. var ignores blocks entirely and belongs to the whole surrounding function.
function report() {
if (true) {
var a = 'var'
let b = 'let'
}
console.log(a) // 'var' - still here
console.log(b) // ReferenceError - gone, as it should be
}That is not a subtlety. It is the reason var produced a generation of bugs where a variable declared inside a loop was still visible, and still holding its last value, long after the loop ended.
Hoisting, and the temporal dead zone
JavaScript processes declarations before it runs the code. That is hoisting, and all three keywords are hoisted. The difference is what the variable holds in the meantime.
console.log(a) // undefined - var is hoisted and initialized
var a = 1
console.log(b) // ReferenceError - let is hoisted but not initialized
let b = 1The window between the top of the block and the let or const line, where the variable exists but cannot be touched, is called the temporal dead zone. It exists so that reading a variable too early is a loud error rather than a quiet undefined.
👨🏫 That is the whole point, and it is worth stating plainly:undefinedpropagates. Avarread too early flows into a comparison, which passes, and your test goes green for the wrong reason. AReferenceErrorstops the test on the exact line that is wrong. Loud beats quiet, every time.
const does not mean immutable
Here is the one that costs teams real money.
const means the binding cannot be reassigned. It says nothing whatsoever about the value.
const customer = { name: 'Acme', size: 'Large' }
customer.size = 'Small' // fine, and this is the problem
customer = {} // TypeError: Assignment to constant variableSame for arrays:
const created = []
created.push('Acme') // fine
created.length = 0 // also fineSo a const array can be filled, emptied, and reordered. A const object can have every property rewritten. The only thing const protects is which value the name points at.
Now put that inside a test suite.
// fixtures/customer.js
export const newCustomer = { name: 'Acme', industry: 'Retail', size: 'Large' }// two specs, in two files, both importing that object
newCustomer.size = 'Small' // spec A adjusts it for its own caseSpec A passes. Spec B, which imported the same object, now receives Small and fails, or worse, passes for the wrong reason. Run them in the other order and the failure moves. That is the classic "it fails in CI but not on my machine" ticket, and const did not stop it for one second.
👨🏫 When a colleague says "it's a const, it can't change", they mean the variable. The bug is in the object. Lesson 5 of this course closes this loop and shows you the copy that fixes it.
The rule this course follows
const by default. let when the value must change. var never.
And the reason, rather than the assertion:
constby default because a reader who seesconstknows the name will still mean the same thing thirty lines down. That is one less thing to hold in your head while reading a spec.letwhen it must change, because pretending otherwise leads to worse contortions than just admitting the value changes.varnever, because its function scope and itsundefined-before-declaration behavior have no upside left. Everythingvarcan do,letdoes more safely.
In spec code
Two patterns come up constantly.
The variable declared in describe and assigned in beforeEach. The value changes per test, so it cannot be const; the assertion needs to see it, so it cannot live inside beforeEach.
A value captured from the page. This one bites everybody once, and it is worth seeing before it happens to you:
let firstName // declared out here, or the assertion cannot see it
cy.get('[data-test="customer-name"]').first().invoke('text').then((text) => {
firstName = text.trim()
})
// ...later, inside another .then(), never at the top levelDeclare it in the describe block and the whole spec can read it. Declare it inside the .then() and it is gone by the closing brace, which is the correct behavior of let and a confusing surprise to anyone who learned var first.
👨🏫 There is a second trap in that snippet, about when the assignment happens rather than where the variable lives. It is the subject of lesson 6, on the event loop. For now, note only the scoping.
Syntax overview 📖
const
Declares a block-scoped binding that cannot be reassigned. The value itself can still be mutated.
Syntax:
const name = valueExample:
describe('Customers table', () => {
const expectedColumns = ['Name', 'Industry', 'Size', 'Registered at', 'Contact email']
it('renders every column', () => {
cy.visit('/')
cy.get('th').should('have.length', expectedColumns.length)
})
})let
Declares a block-scoped binding that can be reassigned.
Syntax:
let name = valueExample:
describe('Sorting', () => {
let firstRowName
beforeEach(() => {
cy.visit('/')
cy.get('[data-test="row-name"]').first().invoke('text').then((text) => {
firstRowName = text.trim()
})
})
it('changes the first row when sorted the other way', () => {
cy.get('[data-test="sort-name"]').click()
cy.get('[data-test="row-name"]').first().should('not.have.text', firstRowName)
})
})Suggested content 📚
- var - MDN
- let - MDN
- const - MDN
- Hoisting - MDN
- Temporal dead zone - MDN
- Variables and Aliases - official Cypress documentation
Exercise 🎯
Open helpers/data.js and find the stub for baseCustomer.
The contract in its doc comment is: return a customer object with the fields name, industry, and size, ready for a spec to fill in a form with.
- Implement it as a
constmodule-level object that the function returns directly. Runnpm run cy:run(ornpm run pw:test). Both specs that use it pass. - Now add a line to the first of those specs that changes
sizeto'Small'on the object it received, and run again. Watch the second spec fail. - Fix it, so that both specs pass no matter which order they run in.
- Write down, in one sentence, why step 2 was possible even though the object was declared with
const.
🙊 Step 1 looks like this, and it is the version that carries the bug:
const BASE = { name: 'Acme', industry: 'Retail', size: 'Large' }
export function baseCustomer() {
return BASE
}Every caller receives the same object, so a change made by one spec is visible to all of them.constnever entered into it: the bindingBASEwas never reassigned, only the object it points at was mutated.
The fix is to hand every caller its own copy:
export function baseCustomer() {
return { name: 'Acme', industry: 'Retail', size: 'Large' }
}The object literal is now inside the function, so every call builds a brand new one. There is nothing left to share, which means there is nothing left to leak. Notice that the fix was not a betterconst: no keyword could have saved the first version, because the problem was one object reaching two specs.
The cost is that the default values are now written inside the function rather than in one named place at the top of the file. Lesson 5 shows how to have both, by keeping a single source object and handing out a copy of it.
And if the function itself is the unfamiliar part here, that is fine: all you need for now is that its body runs again on every call, which is what makes the object new each time. Functions get a lesson of their own, lesson 3, where declarations, arrow functions, parameters, defaults, and closures are covered properly.
Show the world what you learned 🌎
To show your professional network what you learned in this lesson, post the following on LinkedIn.
I am taking the "Algorithms and Programming for QAs" course by @Walmyr Lima e Silva Filho at the @Talking About Testing School, where I learned why const does not mean immutable, how a shared fixture object leaks state between specs even when every declaration is a const, and why block scope makes a test fail loudly instead of quietly. #TalkingAboutTesting #TATSchool #AlgorithmsAndProgrammingForQAs #JavaScript #TestAutomation
👨🏫 Remember to tag me in your post. Here is my LinkedIn profile.
Quiz
A shared fixture is exported as `export const newCustomer = { name: 'Acme', size: 'Large' }`. Spec A sets `newCustomer.size = 'Small'`, and spec B, in another file, then fails. Why did `const` not prevent this?
Score 100% on the quiz to continue