前端测试框架Jest基础入门

目前最流行的前端测试框架是Jest

Jest 是 Facebook 开发的 Javascript 测试框架,用于创建、运行和编写测试的 JavaScript 库。

Jest 开箱即用,具有强大的模拟功能,能并行执行测试,加快速度,是目前前端最流行的测试库之一。

安装依赖

javascript 复制代码
npm install --save-dev jest

javascript 复制代码
pnpm add --save-dev jest

简单示例

新建一个sum.js文件

javascript 复制代码
function sum(a, b) {
  return a + b;
}
module.exports = sum;

并新建sun.test.js文件,这特殊的后缀是 Jest 的约定,便于查找所有的测试文件。

javascript 复制代码
const sum = require("./sum");

test("adds 1 + 2 to equal 3", () => {
  expect(sum(1, 2)).toBe(3);
});

package.json文件添加

javascript 复制代码
"scripts": {
   "test": "jest"
},

运行命令,jest会查找所有测试文件(".test.js"后缀)并做测试

javascript 复制代码
npm run test

结果如下,可以发现测试通过

!image-20240823162633972(/Users/chenkai/Desktop/:Users:chenkai:Library:Application Support:typora-user-images:image-20240823162633972.png)

ES6 module支持

Jest 默认不支持ES6模块,因此直接使用 Jest 运行测试时会抛出此错误。

如果需要支持ES6模块,则必须添加 babel。

安装babel-jest @babel/preset-env依赖

javascript 复制代码
pnpm add --save-dev babel-jest @babel/preset-env

package.json文件添加

javascript 复制代码
"type": "module",

创建jest.config.js配置文件

javascript 复制代码
export default {
  transform: {
    "^.+\\.[t|j]sx?$": "babel-jest",
  },
};

创建babel.config.json配置文件

javascript 复制代码
{
  "presets": ["@babel/preset-env"]
}

匹配器

Jest 使用"匹配器"让您以不同的方式测试值。

toBe

上面的例子中,.toBe(3)是匹配器,判断"期望"值是否完全相等。

明显单这个匹配器无法满足所有测试需求,jest还提供了很多其他匹配器。

toBe并不是用===测试精确相等性,而是基于Object.is,其二者有以下区别

javascript 复制代码
console.log(+0 === -0); // true
console.log(Object.is(+0, -0)); // false
console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true

以下测试会通过

javascript 复制代码
test("toBe", () => {
  expect(NaN).toEqual(NaN);
});

以下测试会失败

javascript 复制代码
test("toBe", () => {
  expect(+0).toEqual(-0);
});

not

可以用not测试匹配器的对立面

javascript 复制代码
test('adding positive numbers is not zero', () => {
  for (let a = 1; a < 10; a++) {
    for (let b = 1; b < 10; b++) {
      expect(a + b).not.toBe(0);
    }
  }
});

toEqual

如果是检测对象或数组的值,会用到toEqualtoEqual会递归检查对象或数组的每个字段。

javascript 复制代码
test("object assignment", () => {
  const data = { a: 1 };
  data["b"] = 2;
  expect(data).toEqual({ a: 1, b: 2 });
});

其实检测普通类型的值也可以用toEqual

javascript 复制代码
test("toEqual", () => {
  const n = 2 + 2;
  const v = "a";
  const a = false;
  expect(n).toEqual(4);
  expect(v).toEqual("a");
  expect(a).toEqual(false);
});

toBeNull

仅匹配null

javascript 复制代码
test("toBeNull", () => {
  const n = null;
  expect(n).toBeNull();
});

toBeUndefined

仅匹配undefined

javascript 复制代码
test("toBeUndefined", () => {
  const n = undefined;
  expect(n).toBeUndefined();
});

toBeDefined

相反的toBeUndefined

javascript 复制代码
test("toBeDefined", () => {
  const a = 1;
  const b = "b";
  const c = false;
  const d = {};
  const e = [];
  const f = undefined;
  expect(a).toBeDefined();
  expect(b).toBeDefined();
  expect(c).toBeDefined();
  expect(d).toBeDefined();
  expect(e).toBeDefined();
  expect(f).not.toBeDefined();
});

toBeTruthy

匹配if视为true的任何内容

javascript 复制代码
test("toBeTruthy", () => {
  const a = 1;
  const b = [];
  const c = false;
  const d = undefined;
  expect(a).toBeTruthy();
  expect(b).toBeTruthy();
  expect(c).not.toBeTruthy();
  expect(d).not.toBeTruthy();
});

toBeFalsy

匹配if视为false的任何内容

javascript 复制代码
test("toBeFalsy", () => {
  const a = 1;
  const b = [];
  const c = false;
  const d = undefined;
  expect(a).not.toBeFalsy();
  expect(b).not.toBeFalsy();
  expect(c).toBeFalsy();
  expect(d).toBeFalsy();
});

toBeGreaterThan

测试"期待"的数字大于指定的值

javascript 复制代码
test("toBeGreaterThan", () => {
  const value = 1 + 3;
  expect(value).toBeGreaterThan(3);
  expect(value).not.toBeGreaterThan(5);
});

toBeGreaterThanOrEqual

测试"期待"的数字大于或等于指定的值

javascript 复制代码
test("toBeGreaterThanOrEqual", () => {
  const value = 1 + 3;
  expect(value).toBeGreaterThanOrEqual(3);
  expect(value).toBeGreaterThanOrEqual(4);
});

toBeLessThan

测试"期待"的数字小于指定的值

javascript 复制代码
test("toBeLessThan", () => {
  const value = 1 + 3;
  expect(value).toBeLessThan(5);
  expect(value).not.toBeLessThan(4);
});

toBeLessThanOrEqual

测试"期待"的数字小于或等于指定的值

javascript 复制代码
test("toBeLessThanOrEqual", () => {
  const value = 1 + 3;
  expect(value).toBeLessThanOrEqual(5);
  expect(value).toBeLessThanOrEqual(4);
});

toBeCloseTo

测试浮点数相等

JavaScript 存在浮点数运算精度丢失的问题。(由于计算机内部使用二进制表示浮点数,而二进制无法精确表示所有的十进制小数,这就导致了所谓的"精度丢失")

javascript 复制代码
console.log(0.1 + 0.2 === 0.3); // false

所以对于浮点数相等,我们应该用toBeCloseTo,而不是toBetoEqual

javascript 复制代码
test("toBeCloseTo", () => {
  const value = 0.1 + 0.2;
  expect(value).not.toBe(0.3);
  expect(value).not.toEqual(0.3);
  expect(value).toBeCloseTo(0.3);
});

toContainEqual

检查具有特定结构和值的项是否包含在数组中。为了测试数组中的项,此匹配器会递归检查所有字段的相等性。

javascript 复制代码
test("toContainEqual", () => {
  const list = [1, 2];
  expect(list).toContainEqual(2);
});

测试异步代码

Promise

在测试中返回一个Promise,Jest 将等待该Promise的状态变为resolve或reject。

javascript 复制代码
const fun = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("a");
    }, 200);
  });

test("the data is a", () => {
  return fun().then((data) => expect(data).toBe("a"));
});

test("the data is a", () => {
  return expect(fun()).resolves.toBe("a");
});
javascript 复制代码
const fun = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("error");
    }, 200);
  });

test("error", () => {
  return fun().catch((data) => expect(data).toBe("error"));
});

test("error", () => {
  return expect(fun()).rejects.toBe("error");
});

Async/Await

javascript 复制代码
const fun = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("a");
  }, 200);
});

test("the data is a", async () => {
  const data = await fun;
  expect(data).toBe("a");
});

test("the data is a", async () => {
  await expect(fun).resolves.toBe("a");
});

回调

也可以使用回调来测试异步代码,使用回调测试异步代码时,需要使用一个名为done的参数。done是一个回调函数,Jest 将等到done回调被调用后才完成测试。

javascript 复制代码
const fetchData = (callback) => {
  setTimeout(() => {
    callback("a");
  }, 200);
};

test("callback", (done) => {
  function callback(data) {
    try {
      expect(data).toBe("a");
      done();
    } catch (error) {
      done(error);
    }
  }

  fetchData(callback);
});

钩子函数

beforeEach

在当前文件中的每一个测试开始之前会调用

javascript 复制代码
let count = 0;
beforeEach(() => {
  count++;
});

test("count is one", () => {
  expect(count).toBe(1);
}); // 测试通过

test("count is two", () => {
  expect(count).toBe(2);
}); // 测试通过

test("count is three", () => {
  expect(count).toBe(3);
}); // 测试通过

afterEach

在当前文件中的每一个测试结束之后会调用

javascript 复制代码
let count = 0;
afterEach(() => {
  count++;
});

test("count is one", () => {
  expect(count).toBe(0);
}); // 测试通过

test("count is two", () => {
  expect(count).toBe(1);
}); // 测试通过

test("count is three", () => {
  expect(count).toBe(2);
}); // 测试通过

beforeAll

在当前文件中的全部测试开始之前进行一次调用,只调用一次

javascript 复制代码
let count = 0;
beforeAll(() => {
  count++;
});

test("count is one", () => {
  expect(count).toBe(1);
}); // 测试通过

test("count is two", () => {
  expect(count).not.toBe(2);
}); // 测试通过

afterAll

在当前文件中的全部测试结束之后进行一次调用,只调用一次

javascript 复制代码
let count = 0;
afterAll(() => {
  count++;
});

test("count is one", () => {
  expect(count).toBe(0);
}); // 测试通过

test("count is two", () => {
  expect(count).not.toBe(1);
}); // 测试通过

describe

顶层的钩子函数适用于文件中的每个测试。在describe块内声明的钩子函数仅适用于该块内的测试。

javascript 复制代码
let count = "a";
beforeEach(() => {
  count = "b";
});

describe("describe", () => {
  beforeEach(() => {
    count = "c";
  });
  test("the data is c", () => {
    expect(count).toBe("c");
  });
});

test("the data is b", () => {
  expect(count).toBe("b");
});

顶层的钩子函数会在describe块内的钩子函数之前执行。

javascript 复制代码
test("", () => console.log("test--1"));

describe("Scoped / Nested block", () => {
  beforeAll(() => console.log("describe - beforeAll"));
  afterAll(() => console.log("describe - afterAll"));
  beforeEach(() => console.log("describe - beforeEach"));
  afterEach(() => console.log("describe - afterEach"));

  test("", () => console.log("describe - test"));
});

beforeEach(() => console.log("beforeEach"));
afterEach(() => console.log("afterEach"));
beforeAll(() => console.log("beforeAll"));
afterAll(() => console.log("afterAll"));

test("", () => console.log("test--2"));

// beforeAll
// beforeEach
// test--1
// afterEach
// describe - beforeAll
// beforeEach
// describe - beforeEach
// describe - test
// describe - afterEach
// afterEach
// describe - afterAll
// beforeEach
// test--2
// afterEach
// afterAll

模拟函数

模拟函数也称为"间谍",因为它们允许您监视由其他代码间接调用的函数的行为,而不仅仅是测试输出。通过jest.fn创建模拟函数,可以捕获函数的调用、参数等数据。如果没有给出实现,则模拟函数将undefined在调用时返回。

创建模拟函数

创建模拟函数可以通过jest.fn()实现。

javascript 复制代码
const mockFn = jest.fn((num) => 7 + num);

console.log(mockFn(1)); // 8
console.log(mockFn(2)); // 9

mockFn.mock

所有模拟函数都有这个特殊.mock属性,其中保存了有关函数如何被调用以及函数返回的数据等等。该.mock属性还跟踪每次调用的值this,因此也可以检查它。

mockFn.mock.calls

包含对此模拟函数进行的所有调用的调用参数的数组

javascript 复制代码
test("fn", () => {
  const myJestFn = jest.fn((a, b) => a + b);
  const result = myJestFn(1, 2);
  myJestFn(result, 3);
  console.log(myJestFn.mock.calls); // [ [ 1, 2 ], [ 3, 3 ] ] 
  // 第一次调用传递的参数是1和2,第二次调用传递的参数是3和3
});

mockFn.mock.lastCall

包含对此模拟函数进行的最后一次调用的调用参数的数组。

javascript 复制代码
test("fn", () => {
  const myJestFn = jest.fn((a, b) => a + b);
  const result = myJestFn(1, 2);
  myJestFn(result, 3);
  // 包含对此模拟函数进行的最后一次调用的调用参数的数组。
  console.log(myJestFn.mock.lastCall); // [3, 3]
});

myJestFn.mock.results

包含对此 mock 函数进行的所有调用的结果的数组。

数组的每一项是一个对象,对象包括

  • type:type有三个值
    • return:表示调用正常返回,完成。
    • throw:表示调用正常返回,完成。
    • incomplete:表示调用尚未完成。
  • value:表示返回的值
type为'return'
javascript 复制代码
test("fn", () => {
  const myJestFn = jest.fn((a, b) => a + b);
  const result = myJestFn(1, 2);
  myJestFn(result, 3);
  
  console.log(myJestFn.mock.results); // [ { type: 'return', value: 3 }, { type: 'return', value: 6 } ]
});
type为'throw'
javascript 复制代码
test("fn", () => {
  const myJestFn = jest.fn(() => {
    throw new Error("This is a mock error");
  });

  // 捕获异常以避免测试直接失败
  try {
    myJestFn();
  } catch (e) {
    // 忽略异常
  }

  console.log(myJestFn.mock.results); 
  /*
  [
      {
        type: 'throw',
        value: Error: This is a mock error
            at ...
            ...
            ...
      }
    ]
  */
});

mockFn.mock.contexts

包含模拟函数所有调用的上下文的数组。上下文是this函数调用时接收的值。

可以使用Function.prototype.bindFunction.prototype.call或来设置上下文Function.prototype.apply

javascript 复制代码
test("fn", () => {
  const AContext = { name: "A" };
  const BContext = { name: "B" };
  const CContext = { name: "C" };

  const mockFn = jest.fn((a) => this.name + a);

  mockFn();
  mockFn.bind(AContext)("a");
  mockFn.call(BContext, "b");
  mockFn.apply(CContext, ["c"]);

  console.log(mockFn.mock.contexts);
  // [ undefined, { name: 'A' }, { name: 'B' }, { name: 'C' } ]
});

mockFn.mock.instances

包含使用此模拟函数实例化的所有对象实例的数组new

javascript 复制代码
test("fn", () => {
  const mockFn = jest.fn();

  const A = new mockFn();
  const B = new mockFn();

  expect(mockFn.mock.instances[0]).toBe(A);
  expect(mockFn.mock.instances[1]).toBe(B);
});

模拟返回值

mockFn.mockReturnValue()

可以使用mockFn.mockReturnValue()设置模拟函数调用时返回的固定值。

javascript 复制代码
test("mockReturnValue", () => {
  const mockFn = jest.fn(() => 10);
  console.log(mockFn()); // 10
  mockFn.mockReturnValue(17);
  console.log(mockFn()); // 17
  console.log(mockFn()); // 17
});

mockFn.mockReturnValueOnce()

如果想要多次调用返回不同的模拟返回值,则可以使用mockFn.mockReturnValueOnce()

javascript 复制代码
test("mockReturnValueOnce", () => {
  const mockFn = jest
    .fn(() => 10)
    .mockReturnValueOnce(17)
    .mockReturnValueOnce(18)
    .mockReturnValueOnce(19);
  console.log(mockFn()); // 17
  console.log(mockFn()); // 18
  console.log(mockFn()); // 19
  console.log(mockFn()); // 10
  console.log(mockFn()); // 10
});

如上面的例子所示,当没有更多的"mockReturnValueOnce"值可用后,将返回原先的返回值。

模拟返回值在实际测试中十分有用,例如当我们在模拟时需要依赖另一个函数,可以使用"mockReturnValue"来模拟依赖函数的返回,从而隔离测试逻辑;或在模拟第三方库时,有时需要模拟第三方库的函数或方法,从而避免实际调用库的逻辑等等

假如有个需要使用axios调用API获取用户数据的逻辑,为了不实际访问API,我们可以使用jest.mock...函数自动模拟axios模块,然后用mockReturnValue返回我们想要测试断言的数据,也就是说我们只需要一个假的响应。

javascript 复制代码
import axios from "axios";
import { fetchUser } from "./userService";

jest.mock("axios");

test("fetchUser returns mock user data", async () => {
  axios.get.mockReturnValue(Promise.resolve({ data: { id: 1, name: "John" } })); // 模拟返回值

  const user = await fetchUser(1);
  expect(user).toEqual({ id: 1, name: "John" });
});

jest.mock 模拟模块

上面例子中用到了jest.mock()jest.mock() 是 Jest 中用于创建模块或文件的模拟(mock)的函数。它允许你在测试中用自定义实现或伪造模块替换实际的依赖模块,方便隔离测试逻辑,控制模块行为。

jest.mock(moduleName, factory, options)

  • moduleName: 要模拟的模块的名称或路径。
  • factory (可选): 一个返回模块模拟实现的函数。
  • options (可选): 配置模拟的选项,例如是否自动清除模拟。

默认情况下,jest.mock() 会为指定模块创建一个空的自动模拟对象。

javascript 复制代码
jest.mock('axios'); // 自动创建模拟

import axios from 'axios';

可以通过工厂函数为模拟模块提供自定义实现。

javascript 复制代码
// foo.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
javascript 复制代码
import foo from "./foo.js";

jest.mock("./foo.js", () => ({
  add: jest.fn(() => 42),
  subtract: jest.fn(() => -1),
}));

test("mock", () => {
  expect(foo.add(1, 2)).toBe(42);
  expect(foo.subtract(3, 2)).toBe(-1);
});

也可以只部分模拟部分

javascript 复制代码
// foo.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export default () => "foo";
javascript 复制代码
import foo, { add, subtract } from "./foo.js";

jest.mock("./foo.js", () => {
  const actual = jest.requireActual("./foo.js"); // 通过jest.requireActual 引用实际模块
  return {
    __esModule: true,
    ...actual,
    default: jest.fn(() => "mocked foo"),
    subtract: jest.fn(() => -1),
  };
});

test("mock", () => {
  expect(foo()).toBe("mocked foo");
  expect(add(1, 2)).toBe(3);
  expect(subtract(3, 2)).toBe(-1);
});

模拟实现

除了上面的模拟返回值,有些情况下,超越指定返回值的能力并完全替换模拟函数的实现是有用的。

mockFn.mockImplementation(fn)

mockFn.mockImplementation(fn),接受一个应该用作模拟实现的函数。模拟本身仍将记录进入它的所有调用和来自它自身的实例。

javascript 复制代码
test("mockFn.mockImplementation", () => {
  const mockFn = jest.fn((num) => {
    return 7 + num;
  });

  console.log(mockFn(1)); // 8
  console.log(mockFn(2)); // 9

  mockFn.mockImplementation((num) => 17 + num);

  console.log(mockFn(1)); // 18
  console.log(mockFn(2)); // 19
});

当你需要定义从另一个模块创建的模拟函数的默认实现时,该方法很有用。

javascript 复制代码
// foo.js
module.exports = function () {
  return "foo";
};
javascript 复制代码
test("foo.mockImplementation", () => {
  jest.mock("./foo");
  const foo = require("./foo");

  foo.mockImplementation(() => "bar");

  console.log(foo()); // bar
});

mockFn.mockImplementationOnce(fn)

当你需要重新创建模拟函数的复杂行为,使得多个函数调用产生不同的结果时,可以使用mockFn.mockImplementationOnce(fn),其接受一个函数,该函数将用作对模拟函数的一次调用的模拟实现。可以链接起来,以便多个函数调用产生不同的结果。

javascript 复制代码
test("mockFn.mockImplementationOnce", () => {
  const myMockFn = jest
    .fn(() => "default")
    .mockImplementationOnce(() => "first call")
    .mockImplementationOnce(() => "second call");
  console.log(myMockFn()); // first call
  console.log(myMockFn()); // second call
  console.log(myMockFn()); // default
});

匹配器

为了减少断言模拟函数如何被调用的难度,jest为模拟函数提供了一些方便的匹配器

toHaveBeenCalled

用于确保模拟函数至少被调用一次

javascript 复制代码
test("toHaveBeenCalled", () => {
  const myMockFn = jest.fn((a, b) => a + b);
  expect(myMockFn).not.toHaveBeenCalled();
  myMockFn(1, 2);
  expect(myMockFn).toHaveBeenCalled();
  // 也可以用以下的简化版(.mock)写法,效果一样
  expect(myMockFn.mock.calls.length).toBeGreaterThan(0);
});

toHaveBeenCalledWith

用于确保模拟函数使用指定的参数至少调用模拟函数一次

javascript 复制代码
test("toHaveBeenCalledWith", () => {
  const myMockFn = jest.fn((a, b) => a + b);
  myMockFn(1, 2);
  expect(myMockFn).not.toHaveBeenCalledWith(1, 3);
  expect(myMockFn).toHaveBeenCalledWith(1, 2);
  // 也可以用以下的简化版(.mock)写法,效果一样
  expect(myMockFn.mock.calls).toContainEqual([1, 2]);
});

toHaveBeenLastCalledWith

用于确保对mock函数的最后一次调用是使用指定的参数调用的

javascript 复制代码
test("toHaveBeenLastCalledWith", () => {
  const myMockFn = jest.fn((a, b) => a + b);
  myMockFn(1, 2);
  myMockFn(3, 4);
  expect(myMockFn).toHaveBeenLastCalledWith(3, 4);
  // 也可以用以下的简化版(.mock)写法,效果一样
  expect(myMockFn.mock.calls[myMockFn.mock.calls.length - 1]).toEqual([3, 4]);
});

持续监听

为了提高效率,可以在jest命令添加启动参数的方式让jest持续监听文件的修改而触发执行测试用例,这样就不用每次修改完再重新执行测试用例了。

只需要修改以下命令

javascript 复制代码
"scripts": {
  "test": "jest --watchAll"
},

这样运行后,就会

生成测试覆盖率报告

单元测试覆盖率(Unit Test Coverage)是一个指标,用来衡量你的代码在单元测试中被测试的程度。它帮助开发者了解在项目中哪些代码已经被测试过,哪些代码可能还存在未测试的风险区域。

测试覆盖率通常以百分比表示。它基于代码的不同维度来计算,例如:语句覆盖率、分支覆盖率、函数覆盖率等。

其中最基础的计算方式为 单元测试覆盖率 = 被测代码行数 / 参测代码总行数 * 100%

只需要在jest.config.js中添加显示覆盖率报告的配置即可

javascript 复制代码
module.exports = {
  collectCoverage: true,
};

运行后如下

图中每个参数的含义如下

  • % Stmts:语句覆盖率,确保每个语句都执行了
  • % Branch:分支覆盖率,确保每个 if 代码块都执行了
  • % Funcs:函数覆盖率,确保每个函数都调用了
  • % Lines :行覆盖率,确保每一行都执行了

也可以在jest.config.js中设置每个参数的覆盖率阀值,来提示用户是否测试质量达标。

javascript 复制代码
module.exports = {
  collectCoverage: true,
  coverageThreshold: {
    global: {
      statements: 90, // 保证所有语句至少执行了90%
      functions: 90, // 保证所有函数至少调用了90%
      branches: 90, // 保证所有分支至少执行了90%
      lines: 90, // 保证所有代码行至少执行了90%
    },
  },
};

运行结果如下

相关推荐
mldong4 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排4 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60615 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角6 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!6 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰6 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi8 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen9 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马9 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu9 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss