Talking About TestingATT</>
Algorithms and Programming for QAs

Score 100% on the quiz to continue

Lesson
Free Preview

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.

ScopeCan be reassignedBefore the declaration line
varThe whole functionYesundefined
letThe enclosing block { }YesThrows a ReferenceError
constThe enclosing block { }NoThrows 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 = 1

The 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: undefined propagates. A var read too early flows into a comparison, which passes, and your test goes green for the wrong reason. A ReferenceError stops 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 variable

Same for arrays:

const created = []
created.push('Acme')      // fine
created.length = 0        // also fine

So 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 case

Spec 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:

  • const by default because a reader who sees const knows 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.
  • let when it must change, because pretending otherwise leads to worse contortions than just admitting the value changes.
  • var never, because its function scope and its undefined-before-declaration behavior have no upside left. Everything var can do, let does 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 level

Declare 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 = value

Example:

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 = value

Example:

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 📚

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.

  1. Implement it as a const module-level object that the function returns directly. Run npm run cy:run (or npm run pw:test). Both specs that use it pass.
  2. Now add a line to the first of those specs that changes size to 'Small' on the object it received, and run again. Watch the second spec fail.
  3. Fix it, so that both specs pass no matter which order they run in.
  4. 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. const never entered into it: the binding BASE was 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 better const: 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

Question 1 of 2
Score: 0

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